This is an automated email from the ASF dual-hosted git repository.

bcall pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 3bdfaa59b3 Fix filter_body plugin: deterministic request blocking and 
response body blocking (#12876)
3bdfaa59b3 is described below

commit 3bdfaa59b3abed57c3a528dfce6beaf19f6210f7
Author: Bryan Call <[email protected]>
AuthorDate: Thu Feb 19 09:50:46 2026 -0800

    Fix filter_body plugin: deterministic request blocking and response body 
blocking (#12876)
    
    * Fix filter_body request body blocking race -- transform blocks after 
headers
      are sent to origin, causing Content-Length mismatch and 30s hang. Set 1s 
origin
      no-activity timeout on block, add SEND_RESPONSE_HDR hook for 
deterministic 403.
    * Conditional SEND_RESPONSE_HDR hook, re-enable response blocking -- only 
attach
      hook when blocking a request body. Re-enable response blocking in tests.
    * Add FILTER_BODY_BLOCK_CANARY to prove blocked body never reaches origin --
      canary prefix in blocked body + excludes rule on verifier server output.
    * Add server/client log_validation to ats_replay.test.ext -- remove 
confusing
      traffic_out node, use contains/excludes directly. Add client validation 
support.
    
    Fixes: #12875
---
 plugins/experimental/filter_body/filter_body.cc    | 75 ++++++++++++++++++++--
 tests/gold_tests/autest-site/ats_replay.test.ext   | 24 +++++++
 .../config/filter_body_response_block.yaml         |  1 -
 .../pluginTest/filter_body/filter_body.replay.yaml | 37 +++++++----
 4 files changed, 115 insertions(+), 22 deletions(-)

diff --git a/plugins/experimental/filter_body/filter_body.cc 
b/plugins/experimental/filter_body/filter_body.cc
index 11f68a42cc..ca7471c71b 100644
--- a/plugins/experimental/filter_body/filter_body.cc
+++ b/plugins/experimental/filter_body/filter_body.cc
@@ -101,6 +101,10 @@ struct TransformData {
   bool                      headers_added = false;
 };
 
+// Forward declaration - send_response_handler is used in execute_actions
+// but defined later in the file.
+int send_response_handler(TSCont contp, TSEvent event, void *edata);
+
 /**
  * @brief Case-insensitive substring search.
  *
@@ -439,10 +443,22 @@ execute_actions(TransformData *data, Rule const *rule, 
std::string const *matche
   if (rule->actions & ACTION_BLOCK) {
     data->blocked = true;
     TSHttpTxnStatusSet(data->txnp, TS_HTTP_STATUS_FORBIDDEN);
-    // Set error body so client gets a proper response
     char const *error_body = "Blocked by content filter";
     TSHttpTxnErrorBodySet(data->txnp, TSstrdup(error_body), 
strlen(error_body), TSstrdup("text/plain"));
-    Dbg(dbg_ctl, "Blocking request due to rule: %s", rule->name.c_str());
+    if (data->direction == Direction::REQUEST) {
+      // Add a SEND_RESPONSE_HDR hook to override the error response to 403.
+      // Only added here (when actually blocking) to avoid hook overhead on
+      // every transaction.  The transform zeros its output, creating a
+      // Content-Length mismatch at the origin.  The short timeout below makes
+      // ATS close the hanging origin connection quickly instead of waiting the
+      // default 30 seconds.
+      TSCont send_resp_contp = TSContCreate(send_response_handler, nullptr);
+      TSHttpTxnHookAdd(data->txnp, TS_HTTP_SEND_RESPONSE_HDR_HOOK, 
send_resp_contp);
+      TSHttpTxnHookAdd(data->txnp, TS_HTTP_TXN_CLOSE_HOOK, send_resp_contp);
+      TSHttpTxnConfigIntSet(data->txnp, 
TS_CONFIG_HTTP_TRANSACTION_NO_ACTIVITY_TIMEOUT_OUT, 1);
+    }
+    Dbg(dbg_ctl, "Blocking %s body due to rule: %s", data->direction == 
Direction::REQUEST ? "request" : "response",
+        rule->name.c_str());
   }
 }
 
@@ -611,19 +627,21 @@ transform_handler(TSCont contp, TSEvent event, void 
*edata ATS_UNUSED)
         }
 
         if (data->blocked) {
-          // Blocking action - complete the transform with zero output
-          // The 403 status we set will cause ATS to generate the error 
response
+          // Zero the transform output so the blocked body is not forwarded.
+          // This creates a Content-Length mismatch at the origin (headers
+          // advertised the original body size but 0 bytes arrive). The short
+          // no-activity timeout set in execute_actions causes ATS to close
+          // the hanging origin connection quickly and generate an error
+          // response. The SEND_RESPONSE_HDR hook then overrides it to 403.
           TSVIONBytesSet(data->output_vio, 0);
           TSVIOReenable(data->output_vio);
 
-          // Consume all remaining input
+          // Consume all remaining input so the client side completes cleanly
           int64_t const remaining = TSIOBufferReaderAvail(reader);
           if (remaining > 0) {
             TSIOBufferReaderConsume(reader, remaining);
           }
           TSVIONDoneSet(write_vio, TSVIONBytesGet(write_vio));
-
-          // Signal write complete
           TSContCall(TSVIOContGet(write_vio), TS_EVENT_VCONN_WRITE_COMPLETE, 
write_vio);
           return 0;
         }
@@ -747,6 +765,49 @@ response_handler(TSCont contp, TSEvent event, void *edata)
   return 0;
 }
 
+/**
+ * @brief Send response handler to enforce 403 for blocked requests.
+ *
+ * Called on TS_HTTP_SEND_RESPONSE_HDR_HOOK only when the request body
+ * transform has blocked a request (hook is added in execute_actions).
+ * The transform zeros its output, creating a Content-Length mismatch that
+ * causes ATS to generate an error response (typically 502). This hook
+ * overrides that error to a deterministic 403 Forbidden.
+ *
+ * @param[in] contp The continuation.
+ * @param[in] event The event type (SEND_RESPONSE_HDR or TXN_CLOSE).
+ * @param[in] edata The HTTP transaction.
+ * @return Always returns 0.
+ */
+int
+send_response_handler(TSCont contp, TSEvent event, void *edata)
+{
+  TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
+
+  if (event == TS_EVENT_HTTP_TXN_CLOSE) {
+    TSContDestroy(contp);
+    TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+    return 0;
+  }
+
+  if (event == TS_EVENT_HTTP_SEND_RESPONSE_HDR) {
+    // This hook is only added when the request body was blocked, so we
+    // unconditionally override the response to 403.
+    TSMBuffer bufp;
+    TSMLoc    hdr_loc;
+    if (TSHttpTxnClientRespGet(txnp, &bufp, &hdr_loc) == TS_SUCCESS) {
+      Dbg(dbg_ctl, "Overriding response to 403 for blocked request");
+      TSHttpHdrStatusSet(bufp, hdr_loc, TS_HTTP_STATUS_FORBIDDEN);
+      TSHttpHdrReasonSet(bufp, hdr_loc, 
TSHttpHdrReasonLookup(TS_HTTP_STATUS_FORBIDDEN),
+                         
strlen(TSHttpHdrReasonLookup(TS_HTTP_STATUS_FORBIDDEN)));
+      TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+    }
+  }
+
+  TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+  return 0;
+}
+
 /**
  * @brief Parse the YAML configuration file.
  *
diff --git a/tests/gold_tests/autest-site/ats_replay.test.ext 
b/tests/gold_tests/autest-site/ats_replay.test.ext
index bb9e19d8c8..c041c42770 100644
--- a/tests/gold_tests/autest-site/ats_replay.test.ext
+++ b/tests/gold_tests/autest-site/ats_replay.test.ext
@@ -178,6 +178,18 @@ def ATSReplayTest(obj, replay_file: str):
         rc = server_config['return_code']
         server.ReturnCode = Any(*rc) if isinstance(rc, list) else rc
 
+    # Configure server log validation if specified.
+    server_log_validation = server_config.get('log_validation', {})
+    if server_log_validation:
+        for contains_entry in server_log_validation.get('contains', []):
+            expression = contains_entry['expression']
+            description = contains_entry.get('description', f'Verify server 
output contains: {expression}')
+            server.Streams.All += Testers.ContainsExpression(expression, 
description)
+        for excludes_entry in server_log_validation.get('excludes', []):
+            expression = excludes_entry['expression']
+            description = excludes_entry.get('description', f'Verify server 
output excludes: {expression}')
+            server.Streams.All += Testers.ExcludesExpression(expression, 
description)
+
     # ATS configuration.
     if not 'ats' in autest_config:
         raise ValueError(f"Replay file {replay_file} does not contain 
'autest.ats' section")
@@ -201,6 +213,18 @@ def ATSReplayTest(obj, replay_file: str):
         rc = client_config['return_code']
         client.ReturnCode = Any(*rc) if isinstance(rc, list) else rc
 
+    # Configure client log validation if specified.
+    client_log_validation = client_config.get('log_validation', {})
+    if client_log_validation:
+        for contains_entry in client_log_validation.get('contains', []):
+            expression = contains_entry['expression']
+            description = contains_entry.get('description', f'Verify client 
output contains: {expression}')
+            client.Streams.All += Testers.ContainsExpression(expression, 
description)
+        for excludes_entry in client_log_validation.get('excludes', []):
+            expression = excludes_entry['expression']
+            description = excludes_entry.get('description', f'Verify client 
output excludes: {expression}')
+            client.Streams.All += Testers.ExcludesExpression(expression, 
description)
+
     if dns:
         ts.StartBefore(dns)
     ts.StartBefore(server)
diff --git 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
index 2888a229f2..a853ec8efa 100644
--- 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
@@ -13,4 +13,3 @@ rules:
         - "SSN:"
         - "password:"
     action: [log, block]
-
diff --git a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml 
b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
index 78f66584f4..5cb4f8bb24 100644
--- a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
+++ b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
@@ -26,10 +26,16 @@ autest:
 
   server:
     name: 'server'
+    process_config:
+      other_args: '--verbose diag'
+    log_validation:
+      excludes:
+        - expression: "FILTER_BODY_BLOCK_CANARY"
+          description: "Verify blocked request body never reached the origin"
 
   client:
     name: 'client'
-    # There's a race condition for when the connection is droped by ATS - this
+    # There's a race condition for when the connection is dropped by ATS - this
     # can result in either a 0 or 1 return code.
     return_code: [0, 1]
 
@@ -122,8 +128,12 @@ autest:
             description: "Verify response block rule matched"
       traffic_out:
         contains:
-          - expression: "Blocking request due to rule"
+          - expression: "Blocking request body due to rule"
             description: "Verify request blocking action was taken"
+          - expression: "Overriding response to 403 for blocked request"
+            description: "Verify SEND_RESPONSE_HDR hook enforced 403 status"
+          - expression: "Blocking response body due to rule"
+            description: "Verify response blocking action was taken"
           - expression: "Added header X-Security-Match"
             description: "Verify header was added for request"
           # Adding an internal response is not supported while streaming the 
body.
@@ -167,11 +177,10 @@ sessions:
   #############################################################################
   # Test 2: Request block - request with XXE pattern is blocked
   #
-  # When blocking request bodies, ATS closes the connection to the origin and
-  # the client either experiences simply a closed connection or, depending upon
-  # timeing, a 502 Bad Gateway response. The plugin cannot send a custom error
-  # response (like 403) because the request headers have already been sent to
-  # the origin by the time the body is inspected.
+  # The plugin detects the malicious pattern in the request body and blocks it.
+  # The transform aborts the origin-side VConn to prevent the body from being
+  # forwarded, and a SEND_RESPONSE_HDR hook overrides whatever error ATS
+  # generates (typically 502) to a deterministic 403.
   #############################################################################
   - transactions:
       - client-request:
@@ -182,10 +191,10 @@ sessions:
             fields:
               - [Host, request-block.example.com]
               - [Content-Type, "application/xml+plus_other_stuff"]
-              - [Content-Length, 49]
+              - [Content-Length, 74]
               - [uuid, request-block-test]
           content:
-            data: '<?xml version="1.0"?><!ENTITY xxe SYSTEM "file:">'
+            data: 'FILTER_BODY_BLOCK_CANARY <?xml version="1.0"?><!ENTITY xxe 
SYSTEM "file:">'
 
         server-response:
           status: 200
@@ -197,7 +206,7 @@ sessions:
             data: "OK"
 
         proxy-response:
-          status: 502
+          status: 403
 
   #############################################################################
   # Test 3: Request header - request passes, header added to server request
@@ -334,9 +343,10 @@ sessions:
           # don't expect to see any external headers added.
 
   #############################################################################
-  # Test 7: Response block - detect pattern and attempt blocking
-  # Note: Response blocking after streaming starts has limitations - the
-  # response will still return 200 but the body will be blocked.
+  # Test 7: Response block - block responses with sensitive data
+  # The response body is blocked (zeroed) when a pattern is detected. Because
+  # response headers (including Content-Length) are committed before the body
+  # is inspected, the client sees a Content-Length mismatch.
   #############################################################################
   - transactions:
       - client-request:
@@ -362,6 +372,5 @@ sessions:
           content:
             data: '{"name": "John", "SSN: 123-45-6789"}'
 
-        # Note that blocking happens after the 200 response headers are sent.
         proxy-response:
           status: 200

Reply via email to