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 26db172f19 Add filter_body plugin for request/response body content 
filtering (#12740)
26db172f19 is described below

commit 26db172f19497554727b03d2b836763b6cdfa7b2
Author: Bryan Call <[email protected]>
AuthorDate: Thu Jan 8 09:41:08 2026 -0800

    Add filter_body plugin for request/response body content filtering (#12740)
    
    This plugin provides streaming body content inspection with configurable
    pattern matching and actions. It can be used to detect and mitigate CVE
    exploits and other malicious content patterns.
    
    Co-authored-by: Brian Neradt <[email protected]>
---
 cmake/ExperimentalPlugins.cmake                    |    1 +
 doc/admin-guide/plugins/filter_body.en.rst         |  449 +++++++++
 doc/admin-guide/plugins/index.en.rst               |    4 +
 plugins/experimental/CMakeLists.txt                |    3 +
 plugins/experimental/filter_body/CMakeLists.txt    |   24 +
 plugins/experimental/filter_body/README.md         |  199 ++++
 plugins/experimental/filter_body/filter_body.cc    | 1040 ++++++++++++++++++++
 tests/gold_tests/autest-site/ats_replay.test.ext   |    8 +
 .../gold_tests/autest-site/trafficserver.test.ext  |   14 +-
 .../config/filter_body_request_block.yaml          |   16 +
 .../config/filter_body_request_header.yaml         |   17 +
 .../config/filter_body_request_log.yaml            |   16 +
 .../config/filter_body_response_block.yaml         |   16 +
 .../config/filter_body_response_header.yaml        |   17 +
 .../config/filter_body_response_log.yaml           |   16 +
 .../pluginTest/filter_body/filter_body.replay.yaml |  365 +++++++
 .../pluginTest/filter_body/filter_body.test.py     |   24 +
 17 files changed, 2223 insertions(+), 6 deletions(-)

diff --git a/cmake/ExperimentalPlugins.cmake b/cmake/ExperimentalPlugins.cmake
index 487e0bfed1..52313acfdd 100644
--- a/cmake/ExperimentalPlugins.cmake
+++ b/cmake/ExperimentalPlugins.cmake
@@ -34,6 +34,7 @@ auto_option(CERT_REPORTING_TOOL FEATURE_VAR 
BUILD_CERT_REPORTING_TOOL DEFAULT ${
 auto_option(CONNECTION_EXEMPT_LIST FEATURE_VAR BUILD_CONNECTION_EXEMPT_LIST 
DEFAULT ${_DEFAULT})
 auto_option(COOKIE_REMAP FEATURE_VAR BUILD_COOKIE_REMAP DEFAULT ${_DEFAULT})
 auto_option(CUSTOM_REDIRECT FEATURE_VAR BUILD_CUSTOM_REDIRECT DEFAULT 
${_DEFAULT})
+auto_option(FILTER_BODY FEATURE_VAR BUILD_FILTER_BODY DEFAULT ${_DEFAULT})
 auto_option(FQ_PACING FEATURE_VAR BUILD_FQ_PACING DEFAULT ${_DEFAULT})
 auto_option(GEOIP_ACL FEATURE_VAR BUILD_GEOIP_ACL DEFAULT ${_DEFAULT})
 auto_option(HEADER_FREQ FEATURE_VAR BUILD_HEADER_FREQ DEFAULT ${_DEFAULT})
diff --git a/doc/admin-guide/plugins/filter_body.en.rst 
b/doc/admin-guide/plugins/filter_body.en.rst
new file mode 100644
index 0000000000..700eb57ae3
--- /dev/null
+++ b/doc/admin-guide/plugins/filter_body.en.rst
@@ -0,0 +1,449 @@
+.. Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.
+
+.. include:: ../../common.defs
+
+.. _admin-plugins-filter_body:
+
+Filter Body Plugin
+******************
+
+Description
+===========
+
+The ``filter_body`` plugin is an experimental plugin that provides streaming
+request and response body content inspection with configurable pattern matching
+and actions. It can be used to detect and mitigate security threats such as CVE
+exploits, XXE (XML External Entity) attacks, SQL injection patterns, and other
+malicious content.
+
+The plugin uses a streaming transform approach, processing data as it arrives
+without buffering the entire request or response body. A small lookback buffer
+(sized to the longest pattern minus one byte) is maintained to detect patterns
+that span chunk boundaries.
+
+Features
+--------
+
+- YAML-based configuration with flexible rule definitions
+- Header-based filtering with AND/OR logic
+- Case-insensitive header matching, case-sensitive body patterns
+- Configurable actions per rule: ``log``, ``block``, ``add_header``
+- Support for both request and response body inspection
+- Configurable HTTP methods to match (GET, POST, PUT, etc.)
+- Per-rule metrics counters for monitoring match activity
+- Streaming transform with lookback buffer for cross-boundary pattern matching
+- Optional ``max_content_length`` to skip inspection of large bodies
+- Optional ``status`` codes to match for response rules
+
+Installation
+============
+
+The ``filter_body`` plugin is an experimental plugin and is not built by 
default.
+To build it, pass ``-DENABLE_FILTER_BODY=ON`` to ``cmake`` when configuring::
+
+    cmake -DENABLE_FILTER_BODY=ON ...
+
+Alternatively, build all experimental plugins at once with
+``-DBUILD_EXPERIMENTAL_PLUGINS=ON``::
+
+    cmake -DBUILD_EXPERIMENTAL_PLUGINS=ON ...
+
+Configuration
+=============
+
+The plugin is configured as a remap plugin with a YAML configuration file::
+
+    map http://example.com/ http://origin.example.com/ @plugin=filter_body.so 
@pparam=filter_body.yaml
+
+The configuration file path can be relative to the |TS| configuration directory
+or an absolute path.
+
+Configuration File Format
+-------------------------
+
+The configuration file uses YAML format with a list of rules. Each rule has a
+``name``, a ``filter`` section containing all filtering criteria, and an
+``action`` section specifying what to do when a match occurs::
+
+    rules:
+      - name: rule_name
+        filter:
+          direction: request    # optional, defaults to request
+          methods:              # for request rules only
+            - POST
+            - PUT
+          max_content_length: 1048576
+          headers:
+            - name: Content-Type
+              patterns:
+                - "application/xml"
+                - "text/xml"
+          body_patterns:
+            - "<!ENTITY"
+            - "<!DOCTYPE"
+        action:
+          - log
+          - block
+          - add_header:
+              X-Security-Match: <rule_name>
+              X-Another-Header: some-value
+
+For response rules, use ``status`` instead of ``methods`` within the ``filter``
+section::
+
+    rules:
+      - name: response_rule
+        filter:
+          direction: response
+          status:               # for response rules only
+            - 200
+            - 201
+          body_patterns:
+            - "sensitive_data"
+        action:
+          - log
+
+Rule Options
+------------
+
+``name`` (required)
+    A unique name for the rule. Used in log messages and metrics when the rule
+    matches. The special placeholder ``<rule_name>`` can be used in header 
values
+    to substitute the rule's name dynamically.
+
+``filter`` (required)
+    A section containing all filtering criteria that determine which requests 
or
+    responses the rule applies to. This section separates the "what to match"
+    from the "what to do" (action).
+
+Filter Options
+--------------
+
+The following options are valid within the ``filter`` section:
+
+``direction`` (optional)
+    Specifies whether to inspect request or response bodies.
+    Valid values: ``request``, ``response``. Default: ``request``.
+
+``methods`` (optional)
+    List of HTTP methods to match. If not specified, all methods are matched.
+    Only valid for request rules. Example: ``[GET, POST, PUT]``.
+
+``status`` (optional)
+    List of HTTP status codes to match. If not specified, all status codes are
+    matched. Only valid for response rules. Example: ``[200, 201]``.
+
+``max_content_length`` (optional)
+    Maximum content length in bytes for body inspection. Bodies larger than
+    this value will not be inspected. If set to 0 or not specified, all bodies
+    are inspected regardless of size.
+
+``headers`` (optional)
+    List of header conditions that must all match (AND logic) for body
+    inspection to occur. Each header can have multiple patterns (OR logic
+    within a single header).
+
+    - ``name``: Header name (case-insensitive matching).
+    - ``patterns``: List of patterns to match against the header value.
+
+``body_patterns`` (required)
+    List of patterns to search for in the body content. Pattern matching is
+    case-sensitive. If any pattern matches, the configured actions are 
executed.
+
+Action Options
+--------------
+
+``action`` (optional)
+    List of actions to take when a pattern matches. Default is ``[log]``.
+    Valid values:
+
+    - ``log``: Log the match to the Traffic Server log.
+    - ``block``: Block the request/response (see Block Action below for 
details).
+    - ``add_header``: Add custom headers to the request/response. This action
+      takes a map of header names to values. Use ``<rule_name>`` in header
+      values to substitute the rule's name dynamically. Example::
+
+          action:
+            - log
+            - add_header:
+                X-Security-Match: <rule_name>
+                X-Custom-Flag: detected
+
+Matching Logic
+==============
+
+Header Matching
+---------------
+
+Headers are matched using the following logic:
+
+1. All configured headers must match (AND logic between headers).
+2. Within each header, any pattern can match (OR logic between patterns).
+3. Header name matching is case-insensitive.
+4. Header value matching is case-insensitive.
+
+For example, with this configuration::
+
+    filter:
+      headers:
+        - name: Content-Type
+          patterns:
+            - "application/xml"
+            - "text/xml"
+        - name: X-Custom-Header
+          patterns:
+            - "value1"
+
+A request must have:
+
+- A ``Content-Type`` header containing either "application/xml" OR "text/xml", 
AND
+- An ``X-Custom-Header`` header containing "value1".
+
+Body Pattern Matching
+---------------------
+
+Body patterns are matched using simple substring search:
+
+- Matching is case-sensitive.
+- Any pattern match triggers the configured actions.
+- The plugin uses a streaming approach with a lookback buffer to handle 
patterns
+  that may span buffer boundaries.
+
+Actions
+=======
+
+Log Action
+----------
+
+When the ``log`` action is configured, pattern matches are logged to the
+Traffic Server error log (``diags.log``). No special debug configuration is
+required - log messages are always written when a pattern matches.
+
+Log messages include the rule name and matched pattern in the format::
+
+    NOTE: [filter_body] Matched rule: <rule_name>, pattern: <pattern>
+
+To also log the headers for debugging, you can configure access logging to
+include request and response headers. See :ref:`admin-logging` for details
+on configuring access logs.
+
+Block Action
+------------
+
+When the ``block`` action is configured, the connections are closed and no
+further data is forwarded.
+
+.. warning::
+
+    Because the plugin uses streaming body inspection, a malicious pattern may
+    not be detected until after some (or all) of the body has already been 
sent.
+    The ``block`` action stops further transmission but cannot recall data
+    already sent. For maximum protection, consider using ``max_content_length``
+    to limit inspection to smaller bodies, or use header-based filtering to
+    reduce the attack surface.
+
+**Request body blocking**: Both the client and origin connections are closed.
+The client does not receive any HTTP response - the connection simply closes.
+This is because body inspection occurs after request headers have been sent to
+the origin.
+
+**Response body blocking**: The HTTP status code has already been sent to the
+client before body inspection begins. The connection is closed, leaving the
+client with a partial response body.
+
+Add Header Action
+-----------------
+
+When the ``add_header`` action is configured, custom headers are added:
+
+- For request rules: Headers are added to the server request (proxy request
+  going to the origin). This header modification occurs during body inspection,
+  after the initial request headers have been read but before they are sent
+  to the origin.
+
+- For response rules: Headers are added to the client response. Since body
+  inspection occurs during response streaming, headers are added before the
+  response body is sent to the client.
+
+The ``add_header`` action takes a map of header names and values::
+
+    action:
+      - add_header:
+          X-Security-Match: <rule_name>
+          X-Custom-Flag: detected
+
+Use the special placeholder ``<rule_name>`` in header values to substitute the
+rule's name dynamically. Multiple headers can be specified in a single
+``add_header`` action.
+
+.. note::
+
+    To verify that headers are being added correctly, you can configure access
+    logging to include the server request headers (for request rules) or client
+    response headers (for response rules). Use log fields like 
``{Server-Request}``
+    or ``{Client-Response}`` in your log format. See :ref:`admin-logging` for
+    details.
+
+Example Configurations
+======================
+
+XXE Attack Detection
+--------------------
+
+Block XML requests containing XXE patterns::
+
+    rules:
+      - name: xxe_detection
+        filter:
+          direction: request
+          methods:
+            - POST
+            - PUT
+          headers:
+            - name: Content-Type
+              patterns:
+                - "application/xml"
+                - "text/xml"
+                - "application/xhtml+xml"
+          body_patterns:
+            - "<!ENTITY"
+            - "<!DOCTYPE"
+            - "SYSTEM"
+            - "PUBLIC"
+        action:
+          - log
+          - block
+
+SQL Injection Detection (Log Only)
+----------------------------------
+
+Log potential SQL injection attempts without blocking::
+
+    rules:
+      - name: sql_injection_detection
+        filter:
+          direction: request
+          methods:
+            - POST
+            - GET
+          max_content_length: 65536
+          body_patterns:
+            - "' OR '"
+            - "'; DROP"
+            - "UNION SELECT"
+            - "1=1"
+        action:
+          - log
+          - add_header:
+              X-Security-Warning: sql-injection-detected
+
+Sensitive Data Detection in Responses
+-------------------------------------
+
+Add a header when response contains sensitive patterns::
+
+    rules:
+      - name: sensitive_data_leak
+        filter:
+          direction: response
+          headers:
+            - name: Content-Type
+              patterns:
+                - "application/json"
+                - "text/html"
+          body_patterns:
+            - "password"
+            - "ssn"
+            - "credit_card"
+        action:
+          - log
+          - add_header:
+              X-Data-Classification: sensitive
+              X-Rule-Matched: <rule_name>
+
+Metrics
+=======
+
+The plugin creates a metrics counter for each configured rule. The counter is
+incremented each time the rule matches a pattern in a request or response body.
+
+Metric names follow this format::
+
+    plugin.filter_body.rule.<rule_name>.matches
+
+For example, a rule named ``xxe_detection`` would have a metric named::
+
+    plugin.filter_body.rule.xxe_detection.matches
+
+You can query these metrics using ``traffic_ctl``, replacing ``<rule_name>`` 
with
+the name from your configuration::
+
+    traffic_ctl metric get plugin.filter_body.rule.<rule_name>.matches
+
+Or list all filter_body metrics::
+
+    traffic_ctl metric match plugin.filter_body
+
+Debugging
+=========
+
+To enable debug output for the plugin, configure debug tags in records.yaml::
+
+    records:
+      proxy.config.diags.debug.enabled: 1
+      proxy.config.diags.debug.tags: filter_body
+
+Debug output includes:
+
+- Configuration loading and rule parsing.
+- Header matching results.
+- Pattern match notifications.
+- Action execution.
+
+Limitations
+===========
+
+1. **Request blocking**: When blocking request bodies, both the client and
+   origin connections are closed. The client does not receive any HTTP response
+   code - the connection simply closes. This is because body inspection occurs
+   after the request headers have already been sent to the origin.
+
+2. **Response blocking**: When blocking response bodies, the HTTP status code
+   has already been sent to the client before body inspection begins. The
+   plugin closes the connection, leaving the client with a partial response
+   body.
+
+3. **Pattern matching**: The plugin uses simple substring matching. Regular
+   expressions are not currently supported.
+
+4. **Memory usage**: The lookback buffer size is determined by the longest
+   body pattern configured. Very long patterns may increase memory usage.
+
+5. **Cross-boundary pattern search**: When searching for patterns that may span
+   buffer block boundaries, the plugin uses a two-phase search. The boundary
+   search copies only a small region (at most 2 * max pattern length bytes) to
+   detect patterns spanning boundaries. The main block search is zero-copy.
+
+6. **Performance**: Body inspection adds processing overhead. Use
+   ``max_content_length`` to limit inspection to smaller bodies when 
appropriate.
+
+See Also
+========
+
+- :doc:`header_rewrite.en` for header-based request/response modification.
+- :doc:`access_control.en` for access control based on various criteria.
diff --git a/doc/admin-guide/plugins/index.en.rst 
b/doc/admin-guide/plugins/index.en.rst
index 8de06d9724..1def2223a7 100644
--- a/doc/admin-guide/plugins/index.en.rst
+++ b/doc/admin-guide/plugins/index.en.rst
@@ -172,6 +172,7 @@ directory of the |TS| source tree. Experimental plugins can 
be compiled by passi
    Cert Reporting Tool <cert_reporting_tool.en>
    Connection Exempt List <connection_exempt_list.en>
    Cookie Remap <cookie_remap.en>
+   Filter Body <filter_body.en>
    GeoIP ACL <geoip_acl.en>
    FQ Pacing <fq_pacing.en>
    Header Frequency <header_freq.en>
@@ -216,6 +217,9 @@ directory of the |TS| source tree. Experimental plugins can 
be compiled by passi
 :doc:`Cookie Remap <cookie_remap.en>`
    Makes decisions on destinations based on cookies.
 
+:doc:`Filter Body <filter_body.en>`
+   Streaming body content inspection with configurable pattern matching for 
detecting security threats.
+
 :doc:`FQ Pacing <fq_pacing.en>`
    FQ Pacing: Rate Limit TCP connections using Linux's Fair Queuing queue 
discipline
 
diff --git a/plugins/experimental/CMakeLists.txt 
b/plugins/experimental/CMakeLists.txt
index 33e9b29faa..8d63d3356d 100644
--- a/plugins/experimental/CMakeLists.txt
+++ b/plugins/experimental/CMakeLists.txt
@@ -38,6 +38,9 @@ endif()
 if(BUILD_CUSTOM_REDIRECT)
   add_subdirectory(custom_redirect)
 endif()
+if(BUILD_FILTER_BODY)
+  add_subdirectory(filter_body)
+endif()
 if(BUILD_FQ_PACING)
   add_subdirectory(fq_pacing)
 endif()
diff --git a/plugins/experimental/filter_body/CMakeLists.txt 
b/plugins/experimental/filter_body/CMakeLists.txt
new file mode 100644
index 0000000000..dd8083c3b4
--- /dev/null
+++ b/plugins/experimental/filter_body/CMakeLists.txt
@@ -0,0 +1,24 @@
+#######################
+#
+#  Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license
+#  agreements.  See the NOTICE file distributed with this work for additional 
information regarding
+#  copyright ownership.  The ASF licenses this file to you under the Apache 
License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with the 
License.  You may obtain
+#  a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software 
distributed under the License
+#  is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 
KIND, either express
+#  or implied. See the License for the specific language governing permissions 
and limitations under
+#  the License.
+#
+#######################
+
+project(filter_body)
+
+add_atsplugin(filter_body filter_body.cc)
+
+target_link_libraries(filter_body PRIVATE yaml-cpp::yaml-cpp)
+
+verify_remap_plugin(filter_body)
diff --git a/plugins/experimental/filter_body/README.md 
b/plugins/experimental/filter_body/README.md
new file mode 100644
index 0000000000..0d270da697
--- /dev/null
+++ b/plugins/experimental/filter_body/README.md
@@ -0,0 +1,199 @@
+# filter_body - Request/Response Body Content Filter Plugin
+
+## Overview
+
+The `filter_body` plugin is a remap plugin that performs zero-copy streaming
+inspection of request or response bodies to detect CVE exploitation attempts
+and other malicious patterns. When configured patterns are matched, the plugin
+can log, block (return 403), and/or add headers.
+
+## Features
+
+- Zero-copy streaming body inspection (no full buffering).
+- Case-insensitive header pattern matching.
+- Case-sensitive body pattern matching.
+- Handles patterns that span buffer boundaries.
+- Per-rule direction: inspect request or response.
+- Configurable actions: log, block, add_header.
+- Optional Content-Length limit to skip large payloads.
+- Per-rule metrics counters.
+
+## Configuration
+
+The plugin uses a YAML configuration file. Usage in `remap.config`:
+
+```
+map http://example.com/ http://origin.com/ @plugin=filter_body.so 
@pparam=filter_body.yaml
+```
+
+### Example Configuration
+
+The configuration uses a `filter` node to group all filtering criteria,
+keeping them separate from the `action`:
+
+```yaml
+rules:
+  # Block XXE attacks in XML requests.
+  - name: "xxe_detection"
+    filter:
+      direction: request              # "request" (default) or "response"
+      methods: [POST]                 # HTTP methods to inspect
+      max_content_length: 1048576     # Skip bodies larger than 1MB
+      headers:
+        - name: "Content-Type"
+          patterns:                   # Case-insensitive, ANY matches (OR)
+            - "application/xml"
+            - "text/xml"
+      body_patterns:                  # Case-sensitive, ANY matches
+        - "<!ENTITY"
+        - "SYSTEM"
+    action:
+      - log
+      - block
+
+  # Detect and tag suspicious API requests.
+  - name: "proto_pollution"
+    filter:
+      direction: request
+      methods: [POST, PUT]
+      headers:
+        - name: "Content-Type"
+          patterns: ["application/json"]
+        - name: "User-Agent"          # ALL headers must match (AND)
+          patterns: ["curl", "python"]
+      body_patterns:
+        - "__proto__"
+        - "constructor"
+    action:
+      - log
+      - add_header:
+          X-Security-Match: "<rule_name>"
+          X-Threat-Type: "proto-pollution"
+
+  # Filter sensitive data from responses.
+  - name: "ssn_leak"
+    filter:
+      direction: response
+      status: [200]                   # Only inspect 200 responses
+      headers:
+        - name: "Content-Type"
+          patterns: ["application/json", "text/html"]
+      body_patterns:
+        - "SSN:"
+        - "social security"
+    action:
+      - log
+      - block
+```
+
+## Configuration Fields
+
+### Top Level
+
+| Field | Description |
+|-------|-------------|
+| `rules` | Array of filter rules. |
+
+### Per-Rule Fields
+
+| Field | Description |
+|-------|-------------|
+| `name` | Rule name (required, used in logging and metrics). |
+| `filter` | Container for all filtering criteria (required). |
+| `action` | Array of actions (default: `[log]`). |
+
+### Filter Section Fields
+
+| Field | Description |
+|-------|-------------|
+| `direction` | `"request"` or `"response"` (default: `request`). |
+| `methods` | Array of HTTP methods to inspect (empty = all, request rules 
only). |
+| `status` | Array of HTTP status codes to match (response rules only). |
+| `max_content_length` | Skip inspection if Content-Length exceeds this value. 
|
+| `headers` | Array of header conditions (ALL must match). |
+| `body_patterns` | Array of body patterns to search for (ANY matches). |
+
+### Actions
+
+- `log` - Log match to `diags.log`.
+- `block` - Return 403 Forbidden.
+- `add_header` - Add configured headers (supports multiple headers and 
`<rule_name>` substitution).
+
+```yaml
+action:
+  - log
+  - add_header:
+      X-Security-Match: "<rule_name>"
+      X-Another-Header: "some-value"
+```
+
+The `<rule_name>` placeholder is replaced with the rule's `name` value at
+runtime.
+
+### Header Conditions
+
+```yaml
+filter:
+  headers:
+    - name: "Content-Type"      # Header name (case-insensitive)
+      patterns:                 # Patterns to match (OR logic, 
case-insensitive)
+        - "application/xml"
+        - "text/xml"
+```
+
+## Matching Logic
+
+1. Rules are evaluated based on direction (request/response).
+2. For body inspection to trigger:
+   - Method must match (if configured, request rules only).
+   - Status code must match (if configured, response rules only).
+   - Content-Length must be ≤ `max_content_length` (if configured).
+   - ALL header conditions must match.
+   - Within each header, ANY pattern matches (OR, case-insensitive).
+3. Body is streamed through and searched for patterns (case-sensitive).
+4. If ANY body pattern matches, configured actions are executed.
+
+## Performance Notes
+
+- Uses zero-copy streaming; data is not buffered entirely.
+- Only a small lookback buffer (`max_pattern_length - 1` bytes) is maintained
+  to detect patterns that span buffer boundaries.
+- Use `max_content_length` to skip inspection of large payloads.
+- Header matching is done before any body processing begins.
+
+## Metrics
+
+The plugin creates a metrics counter for each rule:
+
+```
+plugin.filter_body.rule.<rule_name>.matches
+```
+
+Query with `traffic_ctl`, replacing `<rule_name>` with the name from your 
config:
+
+```bash
+traffic_ctl metric get plugin.filter_body.rule.<rule_name>.matches
+traffic_ctl metric match plugin.filter_body
+```
+
+## Building
+
+Enable with cmake:
+
+```bash
+cmake -DENABLE_FILTER_BODY=ON ...
+```
+
+Or build all experimental plugins:
+
+```bash
+cmake -DBUILD_EXPERIMENTAL_PLUGINS=ON ...
+```
+
+## Documentation
+
+For comprehensive documentation, see the [Admin 
Guide](../../../doc/admin-guide/plugins/filter_body.en.rst).
+
+## License
+
+Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0.
diff --git a/plugins/experimental/filter_body/filter_body.cc 
b/plugins/experimental/filter_body/filter_body.cc
new file mode 100644
index 0000000000..11f68a42cc
--- /dev/null
+++ b/plugins/experimental/filter_body/filter_body.cc
@@ -0,0 +1,1040 @@
+/** @file
+
+  @brief A remap plugin that filters request/response bodies for CVE 
exploitation patterns.
+
+  This plugin performs zero-copy streaming inspection of request or response 
bodies,
+  looking for configured patterns. When a pattern matches, it can log, block 
(403),
+  and/or add a header.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+*/
+
+#include <cstring>
+#include <string>
+#include <vector>
+#include <algorithm>
+#include <cctype>
+
+#include <yaml-cpp/yaml.h>
+
+#include "swoc/TextView.h"
+#include "ts/ts.h"
+#include "ts/remap.h"
+#include "tscore/ink_defs.h"
+
+#define PLUGIN_NAME "filter_body"
+
+namespace
+{
+DbgCtl dbg_ctl{PLUGIN_NAME};
+
+// Action flags
+constexpr unsigned ACTION_LOG        = 1 << 0;
+constexpr unsigned ACTION_BLOCK      = 1 << 1;
+constexpr unsigned ACTION_ADD_HEADER = 1 << 2;
+
+// Direction
+enum class Direction { REQUEST, RESPONSE };
+
+// Header match condition
+struct HeaderCondition {
+  std::string              name;
+  std::vector<std::string> patterns; // case-insensitive match
+};
+
+// Header to add when action triggers
+struct AddHeader {
+  std::string name;
+  std::string value; // supports <rule_name> substitution
+};
+
+// A single filtering rule
+struct Rule {
+  std::string                  name;
+  Direction                    direction = Direction::REQUEST;
+  unsigned                     actions   = ACTION_LOG;  // default: log only
+  std::vector<AddHeader>       add_headers;             // headers to add on 
match
+  std::vector<std::string>     methods;                 // for request rules
+  std::vector<int>             status_codes;            // for response rules
+  int64_t                      max_content_length = -1; // -1 means no limit
+  std::vector<HeaderCondition> headers;
+  std::vector<std::string>     body_patterns; // case-sensitive match
+  size_t                       max_pattern_len = 0;
+  int                          stat_id         = -1; // metrics counter for 
matches (-1 = not created)
+};
+
+// Plugin configuration (per remap instance)
+struct FilterConfig {
+  std::vector<Rule> request_rules;
+  std::vector<Rule> response_rules;
+  size_t            max_lookback = 0; // max pattern length - 1 across all 
rules
+};
+
+// Per-transaction transform data
+struct TransformData {
+  TSHttpTxn                 txnp;
+  Rule const               *matched_rule = nullptr;
+  FilterConfig const       *config       = nullptr;
+  std::vector<Rule const *> active_rules; // rules that passed header check
+  std::string               lookback;     // small buffer for cross-boundary 
patterns
+  TSIOBuffer                output_buffer = nullptr;
+  TSIOBufferReader          output_reader = nullptr;
+  TSVIO                     output_vio    = nullptr;
+  Direction                 direction     = Direction::REQUEST; // direction 
of this transform
+  bool                      blocked       = false;
+  bool                      headers_added = false;
+};
+
+/**
+ * @brief Case-insensitive substring search.
+ *
+ * Searches for @a needle within @a haystack using case-insensitive comparison.
+ *
+ * @param[in] haystack The string to search within.
+ * @param[in] needle   The pattern to search for.
+ * @return Pointer to the first occurrence of needle in haystack, or nullptr 
if not found.
+ */
+const char *
+strcasestr_local(swoc::TextView haystack, swoc::TextView needle)
+{
+  if (needle.empty() || haystack.size() < needle.size()) {
+    return nullptr;
+  }
+
+  for (size_t i = 0; i <= haystack.size() - needle.size(); ++i) {
+    if (haystack.substr(i, needle.size()).starts_with_nocase(needle)) {
+      return haystack.data() + i;
+    }
+  }
+  return nullptr;
+}
+
+/**
+ * @brief Case-sensitive substring search.
+ *
+ * Searches for @a needle within @a haystack using exact (case-sensitive) 
comparison.
+ *
+ * @param[in] haystack The string to search within.
+ * @param[in] needle   The pattern to search for.
+ * @return Pointer to the first occurrence of needle in haystack, or nullptr 
if not found.
+ */
+const char *
+strstr_local(swoc::TextView haystack, swoc::TextView needle)
+{
+  if (needle.empty() || haystack.size() < needle.size()) {
+    return nullptr;
+  }
+
+  auto pos = haystack.find(needle);
+  if (pos != std::string::npos) {
+    return haystack.data() + pos;
+  }
+  return nullptr;
+}
+
+/**
+ * @brief Check if the HTTP method matches the rule's method filter.
+ *
+ * If the rule has no method restrictions, all methods match.
+ *
+ * @param[in] rule    The rule containing method restrictions.
+ * @param[in] bufp    The message buffer containing the HTTP headers.
+ * @param[in] hdr_loc The location of the HTTP header.
+ * @return true if the method matches or no method restriction exists, false 
otherwise.
+ */
+bool
+method_matches(Rule const &rule, TSMBuffer bufp, TSMLoc hdr_loc)
+{
+  if (rule.methods.empty()) {
+    return true;
+  }
+
+  int         method_len = 0;
+  const char *method     = TSHttpHdrMethodGet(bufp, hdr_loc, &method_len);
+  if (method == nullptr) {
+    return false;
+  }
+
+  swoc::TextView method_view(method, method_len);
+  method_view.trim_if(::isspace);
+
+  for (auto const &m : rule.methods) {
+    if (0 == strcasecmp(method_view, swoc::TextView(m))) {
+      return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * @brief Check if the HTTP status code matches the rule's status filter.
+ *
+ * For response rules, this checks if the response status code is in the rule's
+ * allowed status codes list.
+ *
+ * @param[in] rule    The rule containing the status code filter.
+ * @param[in] bufp    The message buffer containing the HTTP response.
+ * @param[in] hdr_loc The location of the HTTP response header.
+ * @return true if the status matches or no status restriction exists, false 
otherwise.
+ */
+bool
+status_matches(Rule const &rule, TSMBuffer bufp, TSMLoc hdr_loc)
+{
+  if (rule.status_codes.empty()) {
+    return true; // no status restriction
+  }
+
+  TSHttpStatus status = TSHttpHdrStatusGet(bufp, hdr_loc);
+  for (int const code : rule.status_codes) {
+    if (static_cast<int>(status) == code) {
+      return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * @brief Check if Content-Length is within the rule's max_content_length 
limit.
+ *
+ * If the rule has no content length limit (max_content_length < 0), all sizes 
are allowed.
+ * If the Content-Length header is missing, the check passes.
+ *
+ * @param[in] rule    The rule containing the content length limit.
+ * @param[in] bufp    The message buffer containing the HTTP headers.
+ * @param[in] hdr_loc The location of the HTTP header.
+ * @return true if content length is within limit or no limit exists, false 
otherwise.
+ */
+bool
+content_length_ok(Rule const &rule, TSMBuffer bufp, TSMLoc hdr_loc)
+{
+  if (rule.max_content_length < 0) {
+    return true; // no limit
+  }
+
+  TSMLoc field_loc = TSMimeHdrFieldFind(bufp, hdr_loc, 
TS_MIME_FIELD_CONTENT_LENGTH, TS_MIME_LEN_CONTENT_LENGTH);
+  if (field_loc == TS_NULL_MLOC) {
+    return true; // no Content-Length header, allow
+  }
+
+  int64_t content_length = TSMimeHdrFieldValueInt64Get(bufp, hdr_loc, 
field_loc, 0);
+  TSHandleMLocRelease(bufp, hdr_loc, field_loc);
+
+  return content_length <= rule.max_content_length;
+}
+
+/**
+ * @brief Check if a single header condition matches.
+ *
+ * Uses case-insensitive pattern search. Returns true if any pattern in the
+ * condition matches any value of the specified header (OR logic within 
header).
+ *
+ * @param[in] cond    The header condition to check.
+ * @param[in] bufp    The message buffer containing the HTTP headers.
+ * @param[in] hdr_loc The location of the HTTP header.
+ * @return true if the header exists and any pattern matches, false otherwise.
+ */
+bool
+header_condition_matches(HeaderCondition const &cond, TSMBuffer bufp, TSMLoc 
hdr_loc)
+{
+  TSMLoc field_loc = TSMimeHdrFieldFind(bufp, hdr_loc, cond.name.c_str(), 
static_cast<int>(cond.name.length()));
+  if (field_loc == TS_NULL_MLOC) {
+    return false;
+  }
+
+  bool matched    = false;
+  int  num_values = TSMimeHdrFieldValuesCount(bufp, hdr_loc, field_loc);
+  for (int i = 0; i < num_values && !matched; ++i) {
+    int         value_len = 0;
+    const char *value     = TSMimeHdrFieldValueStringGet(bufp, hdr_loc, 
field_loc, i, &value_len);
+    if (value == nullptr) {
+      continue;
+    }
+
+    swoc::TextView value_view(value, value_len);
+    for (auto const &pattern : cond.patterns) {
+      if (strcasestr_local(value_view, swoc::TextView(pattern)) != nullptr) {
+        matched = true;
+        break;
+      }
+    }
+  }
+
+  TSHandleMLocRelease(bufp, hdr_loc, field_loc);
+  return matched;
+}
+
+/**
+ * @brief Check if ALL header conditions in a rule match.
+ *
+ * Uses AND logic between headers - all header conditions must match for the
+ * rule to apply.
+ *
+ * @param[in] rule    The rule containing header conditions.
+ * @param[in] bufp    The message buffer containing the HTTP headers.
+ * @param[in] hdr_loc The location of the HTTP header.
+ * @return true if all header conditions match, false otherwise.
+ */
+bool
+headers_match(Rule const &rule, TSMBuffer bufp, TSMLoc hdr_loc)
+{
+  for (auto const &cond : rule.headers) {
+    if (!header_condition_matches(cond, bufp, hdr_loc)) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/**
+ * @brief Search for body patterns in the given data.
+ *
+ * Searches for any of the rule's body patterns in the data using 
case-sensitive
+ * matching. Returns the first matched pattern.
+ *
+ * @param[in] rule The rule containing body patterns to search for.
+ * @param[in] data The data buffer to search within.
+ * @return Pointer to the matched pattern string, or nullptr if no match.
+ */
+std::string const *
+search_body_patterns(Rule const &rule, swoc::TextView data)
+{
+  for (auto const &pattern : rule.body_patterns) {
+    if (strstr_local(data, swoc::TextView(pattern)) != nullptr) {
+      return &pattern;
+    }
+  }
+  return nullptr;
+}
+
+/**
+ * @brief Add a header field to an HTTP message.
+ *
+ * Creates and appends a new header field with the given name and value.
+ *
+ * @param[in] bufp    The message buffer to add the header to.
+ * @param[in] hdr_loc The location of the HTTP header.
+ * @param[in] name    The header field name.
+ * @param[in] value   The header field value.
+ */
+void
+add_header_to_message(TSMBuffer bufp, TSMLoc hdr_loc, std::string const &name, 
std::string const &value)
+{
+  TSMLoc field_loc;
+  if (TSMimeHdrFieldCreateNamed(bufp, hdr_loc, name.c_str(), 
static_cast<int>(name.length()), &field_loc) != TS_SUCCESS) {
+    TSError("[%s] Failed to create header field: %s", PLUGIN_NAME, 
name.c_str());
+    return;
+  }
+
+  if (TSMimeHdrFieldValueStringSet(bufp, hdr_loc, field_loc, -1, 
value.c_str(), static_cast<int>(value.length())) != TS_SUCCESS) {
+    TSError("[%s] Failed to set header value: %s", PLUGIN_NAME, name.c_str());
+    TSHandleMLocRelease(bufp, hdr_loc, field_loc);
+    return;
+  }
+
+  if (TSMimeHdrFieldAppend(bufp, hdr_loc, field_loc) != TS_SUCCESS) {
+    TSError("[%s] Failed to append header field: %s", PLUGIN_NAME, 
name.c_str());
+  }
+
+  TSHandleMLocRelease(bufp, hdr_loc, field_loc);
+}
+
+/**
+ * @brief Substitute <rule_name> placeholder in header value.
+ *
+ * @param[in] value     The header value that may contain <rule_name>.
+ * @param[in] rule_name The rule name to substitute.
+ * @return The value with <rule_name> replaced by the actual rule name.
+ */
+std::string
+substitute_rule_name(std::string const &value, std::string const &rule_name)
+{
+  std::string       result      = value;
+  std::string const placeholder = "<rule_name>";
+  size_t            pos         = 0;
+  while ((pos = result.find(placeholder, pos)) != std::string::npos) {
+    result.replace(pos, placeholder.length(), rule_name);
+    pos += rule_name.length();
+  }
+  return result;
+}
+
+/**
+ * @brief Execute the configured actions for a matched rule.
+ *
+ * Performs the actions specified in the rule: log, add_header, and/or block.
+ * For request rules, headers are added to the server request (proxy request 
to origin).
+ * For response rules, headers are added to the client response.
+ *
+ * @note Headers are added during body inspection, which occurs after headers 
may have
+ *       already been sent. For request transforms, the server request headers 
should
+ *       still be modifiable. For response transforms, headers are added 
before the
+ *       response is sent to the client.
+ *
+ * @param[in,out] data            The transform data containing transaction 
state.
+ * @param[in]     rule            The matched rule containing actions to 
execute.
+ * @param[in]     matched_pattern The pattern that triggered the match (for 
logging).
+ */
+void
+execute_actions(TransformData *data, Rule const *rule, std::string const 
*matched_pattern)
+{
+  // Increment the metrics counter for this rule (stat_id is guaranteed valid 
at load time)
+  TSStatIntIncrement(rule->stat_id, 1);
+
+  // Log action always writes to diags.log so it doesn't require debug tags
+  if (rule->actions & ACTION_LOG) {
+    TSError("[%s] Matched rule: %s, pattern: %s", PLUGIN_NAME, 
rule->name.c_str(),
+            matched_pattern ? matched_pattern->c_str() : "unknown");
+  }
+
+  if ((rule->actions & ACTION_ADD_HEADER) && !data->headers_added && 
!rule->add_headers.empty()) {
+    TSMBuffer bufp;
+    TSMLoc    hdr_loc;
+    bool      success = false;
+
+    if (data->direction == Direction::REQUEST) {
+      // For request rules: add headers to server request (proxy request going 
to origin)
+      if (TSHttpTxnServerReqGet(data->txnp, &bufp, &hdr_loc) == TS_SUCCESS) {
+        for (auto const &hdr : rule->add_headers) {
+          std::string value = substitute_rule_name(hdr.value, rule->name);
+          add_header_to_message(bufp, hdr_loc, hdr.name, value);
+          Dbg(dbg_ctl, "Added header %s: %s to server request", 
hdr.name.c_str(), value.c_str());
+        }
+        TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+        success = true;
+      }
+    } else {
+      // For response rules: add headers to client response
+      if (TSHttpTxnClientRespGet(data->txnp, &bufp, &hdr_loc) == TS_SUCCESS) {
+        for (auto const &hdr : rule->add_headers) {
+          std::string value = substitute_rule_name(hdr.value, rule->name);
+          add_header_to_message(bufp, hdr_loc, hdr.name, value);
+          Dbg(dbg_ctl, "Added header %s: %s to client response", 
hdr.name.c_str(), value.c_str());
+        }
+        TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+        success = true;
+      }
+    }
+
+    if (success) {
+      data->headers_added = true;
+    }
+  }
+
+  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());
+  }
+}
+
+/**
+ * @brief Transform continuation handler for streaming body inspection.
+ *
+ * Processes body data in a streaming fashion, searching for patterns across
+ * buffer blocks. Uses a lookback buffer to detect patterns that span block
+ * boundaries.
+ *
+ * @note The pattern search creates a temporary string when the lookback buffer
+ *       is non-empty, which involves a memory copy. This is necessary to 
handle
+ *       patterns spanning buffer boundaries.
+ *
+ * @param[in] contp The transform continuation.
+ * @param[in] event The event type (WRITE_READY, WRITE_COMPLETE, ERROR).
+ * @param[in] edata Event data (unused).
+ * @return Always returns 0.
+ */
+int
+transform_handler(TSCont contp, TSEvent event, void *edata ATS_UNUSED)
+{
+  if (TSVConnClosedGet(contp)) {
+    auto *data = static_cast<TransformData *>(TSContDataGet(contp));
+    if (data) {
+      if (data->output_reader) {
+        TSIOBufferReaderFree(data->output_reader);
+      }
+      if (data->output_buffer) {
+        TSIOBufferDestroy(data->output_buffer);
+      }
+      delete data;
+    }
+    TSContDestroy(contp);
+    return 0;
+  }
+
+  auto *data = static_cast<TransformData *>(TSContDataGet(contp));
+  if (data == nullptr) {
+    return 0;
+  }
+
+  switch (event) {
+  case TS_EVENT_ERROR: {
+    TSVIO write_vio = TSVConnWriteVIOGet(contp);
+    TSContCall(TSVIOContGet(write_vio), TS_EVENT_ERROR, write_vio);
+    break;
+  }
+
+  case TS_EVENT_VCONN_WRITE_COMPLETE:
+    TSVConnShutdown(TSTransformOutputVConnGet(contp), 0, 1);
+    break;
+
+  case TS_EVENT_VCONN_WRITE_READY:
+  default: {
+    // Get the write VIO
+    TSVIO write_vio = TSVConnWriteVIOGet(contp);
+    if (!TSVIOBufferGet(write_vio)) {
+      // No more data
+      if (data->output_vio) {
+        TSVIONBytesSet(data->output_vio, TSVIONDoneGet(write_vio));
+        TSVIOReenable(data->output_vio);
+      }
+      return 0;
+    }
+
+    // Initialize output buffer if needed
+    if (!data->output_buffer) {
+      TSVConn output_conn = TSTransformOutputVConnGet(contp);
+      data->output_buffer = TSIOBufferCreate();
+      data->output_reader = TSIOBufferReaderAlloc(data->output_buffer);
+
+      int64_t nbytes   = TSVIONBytesGet(write_vio);
+      data->output_vio = TSVConnWrite(output_conn, contp, data->output_reader, 
nbytes);
+    }
+
+    // Process available data
+    int64_t towrite = TSVIONTodoGet(write_vio);
+    if (towrite > 0 && !data->blocked) {
+      TSIOBufferReader reader = TSVIOReaderGet(write_vio);
+      int64_t          avail  = TSIOBufferReaderAvail(reader);
+      if (avail > towrite) {
+        avail = towrite;
+      }
+
+      if (avail > 0) {
+        // Zero-copy: iterate through buffer blocks
+        // Stop iterating if we've already found a match (matched_rule != 
nullptr)
+        TSIOBufferBlock block = TSIOBufferReaderStart(reader);
+        while (block != nullptr && !data->matched_rule) {
+          int64_t     block_avail = 0;
+          const char *block_data  = TSIOBufferBlockReadStart(block, reader, 
&block_avail);
+
+          if (block_data && block_avail > 0) {
+            // Two-phase search to minimize memory copying:
+            //
+            // Phase 1 (boundary search): When we have lookback data, create a 
small
+            // buffer containing the lookback + first few bytes of the current 
block.
+            // This catches patterns that span block boundaries. The copy is 
limited
+            // to at most (2 * max_lookback) bytes.
+            //
+            // Phase 2 (block search): Search the remainder of the current 
block
+            // in-place (zero-copy). This catches patterns entirely within the 
block
+            // that weren't already covered by Phase 1.
+
+            size_t search_offset = 0; // Where to start Phase 2 search
+
+            // Phase 1: Boundary search (only when we have lookback data)
+            // Skip if we've already found a match (matched_rule != nullptr)
+            if (!data->lookback.empty() && !data->matched_rule) {
+              // Create boundary buffer: lookback + enough of block to fully 
contain any
+              // pattern that starts within the first max_lookback bytes of 
the block.
+              // We need 2*max_lookback bytes from the block to ensure a 
max-length pattern
+              // starting at position (max_lookback-1) is fully contained.
+              size_t      boundary_extent = 
std::min(static_cast<size_t>(block_avail), 2 * data->config->max_lookback);
+              std::string boundary_buffer;
+              boundary_buffer.reserve(data->lookback.length() + 
boundary_extent);
+              boundary_buffer = data->lookback;
+              boundary_buffer.append(block_data, boundary_extent);
+
+              // Search boundary for patterns spanning block boundaries or 
starting near boundary
+              for (Rule const *rule : data->active_rules) {
+                std::string const *matched = search_body_patterns(*rule, 
swoc::TextView(boundary_buffer));
+                if (matched) {
+                  data->matched_rule = rule;
+                  execute_actions(data, rule, matched);
+                  break; // Stop searching after first match
+                }
+              }
+
+              // Phase 2 starts after max_lookback bytes - these are 
guaranteed to be fully
+              // searchable in Phase 1's boundary_buffer, avoiding duplicate 
detection
+              search_offset = std::min(static_cast<size_t>(block_avail), 
data->config->max_lookback);
+            }
+
+            // Phase 2: Search remainder of block in-place (zero-copy)
+            // Skip if we've already found a match or bytes already covered by 
Phase 1
+            if (!data->matched_rule && search_offset < 
static_cast<size_t>(block_avail)) {
+              for (Rule const *rule : data->active_rules) {
+                std::string const *matched = search_body_patterns(
+                  *rule, swoc::TextView(block_data + search_offset, 
static_cast<size_t>(block_avail) - search_offset));
+                if (matched) {
+                  data->matched_rule = rule;
+                  execute_actions(data, rule, matched);
+                  break; // Stop searching after first match
+                }
+              }
+            }
+
+            // Update lookback buffer (only keep last max_lookback bytes)
+            // Skip if we've found a match - no need to search further blocks
+            if (data->config->max_lookback > 0 && !data->matched_rule) {
+              size_t lookback_size = data->config->max_lookback;
+              if (static_cast<size_t>(block_avail) >= lookback_size) {
+                data->lookback.assign(block_data + block_avail - 
lookback_size, lookback_size);
+              } else {
+                data->lookback.append(block_data, block_avail);
+                if (data->lookback.length() > lookback_size) {
+                  data->lookback = 
data->lookback.substr(data->lookback.length() - lookback_size);
+                }
+              }
+            }
+          }
+
+          block = TSIOBufferBlockNext(block);
+        }
+
+        if (data->blocked) {
+          // Blocking action - complete the transform with zero output
+          // The 403 status we set will cause ATS to generate the error 
response
+          TSVIONBytesSet(data->output_vio, 0);
+          TSVIOReenable(data->output_vio);
+
+          // Consume all remaining input
+          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;
+        }
+
+        // Zero-copy: copy data through to output
+        TSIOBufferCopy(data->output_buffer, reader, avail, 0);
+        TSIOBufferReaderConsume(reader, avail);
+        TSVIONDoneSet(write_vio, TSVIONDoneGet(write_vio) + avail);
+      }
+    }
+
+    // Check if we're done
+    if (TSVIONTodoGet(write_vio) > 0) {
+      if (towrite > 0) {
+        TSVIOReenable(data->output_vio);
+        TSContCall(TSVIOContGet(write_vio), TS_EVENT_VCONN_WRITE_READY, 
write_vio);
+      }
+    } else {
+      TSVIONBytesSet(data->output_vio, TSVIONDoneGet(write_vio));
+      TSVIOReenable(data->output_vio);
+      TSContCall(TSVIOContGet(write_vio), TS_EVENT_VCONN_WRITE_COMPLETE, 
write_vio);
+    }
+    break;
+  }
+  }
+
+  return 0;
+}
+
+/**
+ * @brief Create a transform continuation for body inspection.
+ *
+ * Allocates and initializes a TransformData structure and creates a transform
+ * continuation that will process the body data.
+ *
+ * @param[in] txnp         The HTTP transaction.
+ * @param[in] config       The plugin configuration.
+ * @param[in] active_rules The rules that passed header matching and should be 
checked.
+ * @param[in] dir          The direction (request or response) for this 
transform.
+ * @return The transform virtual connection.
+ */
+TSVConn
+create_transform(TSHttpTxn txnp, FilterConfig const *config, std::vector<Rule 
const *> const &active_rules, Direction dir)
+{
+  TSVConn connp = TSTransformCreate(transform_handler, txnp);
+
+  auto *data         = new TransformData();
+  data->txnp         = txnp;
+  data->config       = config;
+  data->active_rules = active_rules;
+  data->direction    = dir;
+
+  // Pre-allocate lookback buffer
+  if (config->max_lookback > 0) {
+    data->lookback.reserve(config->max_lookback);
+  }
+
+  TSContDataSet(connp, data);
+  return connp;
+}
+
+/**
+ * @brief Response handler for response rules.
+ *
+ * Called on TS_HTTP_READ_RESPONSE_HDR_HOOK to check response rules and add
+ * a response transform if any rules match. Also handles TS_HTTP_TXN_CLOSE_HOOK
+ * to clean up the continuation. Request rules are handled directly in 
TSRemapDoRemap.
+ *
+ * @param[in] contp The continuation (contains FilterConfig pointer).
+ * @param[in] event The event type (READ_RESPONSE_HDR or TXN_CLOSE).
+ * @param[in] edata The HTTP transaction.
+ * @return Always returns 0.
+ */
+int
+response_handler(TSCont contp, TSEvent event, void *edata)
+{
+  TSHttpTxn           txnp   = static_cast<TSHttpTxn>(edata);
+  FilterConfig const *config = static_cast<FilterConfig const 
*>(TSContDataGet(contp));
+
+  // Handle transaction close - clean up continuation
+  if (event == TS_EVENT_HTTP_TXN_CLOSE) {
+    TSContDestroy(contp);
+    TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+    return 0;
+  }
+
+  if (config == nullptr) {
+    TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+    return 0;
+  }
+
+  TSMBuffer bufp;
+  TSMLoc    hdr_loc;
+
+  std::vector<Rule const *> active_rules;
+
+  if (event == TS_EVENT_HTTP_READ_RESPONSE_HDR) {
+    // Check response rules
+    if (TSHttpTxnServerRespGet(txnp, &bufp, &hdr_loc) != TS_SUCCESS) {
+      TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+      return 0;
+    }
+
+    for (auto const &rule : config->response_rules) {
+      // For response rules: check status codes and headers on response
+      if (status_matches(rule, bufp, hdr_loc) && content_length_ok(rule, bufp, 
hdr_loc) && headers_match(rule, bufp, hdr_loc)) {
+        Dbg(dbg_ctl, "Response rule '%s' header conditions matched, will 
inspect body", rule.name.c_str());
+        active_rules.push_back(&rule);
+      }
+    }
+
+    TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+
+    if (!active_rules.empty()) {
+      TSVConn transform = create_transform(txnp, config, active_rules, 
Direction::RESPONSE);
+      TSHttpTxnHookAdd(txnp, TS_HTTP_RESPONSE_TRANSFORM_HOOK, transform);
+    }
+  }
+
+  TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+  return 0;
+}
+
+/**
+ * @brief Parse the YAML configuration file.
+ *
+ * Loads and parses the YAML configuration file, creating Rule objects for each
+ * rule definition. Rules are separated into request_rules and response_rules
+ * based on their direction setting. Filtering criteria are contained within a
+ * 'filter' node to separate them from actions.
+ *
+ * @param[in] filename The configuration file path (absolute or relative to 
config dir).
+ * @return Pointer to the parsed FilterConfig, or nullptr on error.
+ */
+FilterConfig *
+parse_config(const char *filename)
+{
+  std::string path;
+  if (filename[0] == '/') {
+    path = filename;
+  } else {
+    path = std::string(TSConfigDirGet()) + "/" + filename;
+  }
+
+  Dbg(dbg_ctl, "Loading configuration from %s", path.c_str());
+
+  YAML::Node root;
+  try {
+    root = YAML::LoadFile(path);
+  } catch (const std::exception &ex) {
+    TSError("[%s] Failed to load config file '%s': %s", PLUGIN_NAME, 
path.c_str(), ex.what());
+    return nullptr;
+  }
+
+  auto *config = new FilterConfig();
+
+  try {
+    if (!root["rules"]) {
+      TSError("[%s] No 'rules' section in config", PLUGIN_NAME);
+      delete config;
+      return nullptr;
+    }
+
+    for (auto const &rule_node : root["rules"]) {
+      Rule rule;
+
+      // Name (required)
+      if (rule_node["name"]) {
+        rule.name = rule_node["name"].as<std::string>();
+      } else {
+        TSError("[%s] Rule missing 'name' field", PLUGIN_NAME);
+        delete config;
+        return nullptr;
+      }
+
+      // Filter node is required (contains all filtering criteria)
+      YAML::Node filter_node = rule_node["filter"];
+      if (!filter_node) {
+        TSError("[%s] Rule '%s' missing 'filter' node", PLUGIN_NAME, 
rule.name.c_str());
+        delete config;
+        return nullptr;
+      }
+
+      // Direction (default: request) - from filter node
+      if (filter_node["direction"]) {
+        std::string dir = filter_node["direction"].as<std::string>();
+        if (dir == "response") {
+          rule.direction = Direction::RESPONSE;
+        } else {
+          rule.direction = Direction::REQUEST;
+        }
+      }
+
+      // Actions (default: [log])
+      // Supports string actions: "log", "block"
+      // Supports map actions with add_header:
+      //   - add_header:
+      //       X-Header-Name: header-value
+      //       X-Another: <rule_name>
+      rule.actions = 0;
+      if (rule_node["action"]) {
+        for (auto const &action_node : rule_node["action"]) {
+          if (action_node.IsScalar()) {
+            std::string action = action_node.as<std::string>();
+            if (action == "log") {
+              rule.actions |= ACTION_LOG;
+            } else if (action == "block") {
+              rule.actions |= ACTION_BLOCK;
+            }
+          } else if (action_node.IsMap() && action_node["add_header"]) {
+            rule.actions             |= ACTION_ADD_HEADER;
+            auto const &headers_node  = action_node["add_header"];
+            for (auto const &hdr : headers_node) {
+              AddHeader add_hdr;
+              add_hdr.name  = hdr.first.as<std::string>();
+              add_hdr.value = hdr.second.as<std::string>();
+              rule.add_headers.push_back(add_hdr);
+            }
+          }
+        }
+      }
+      if (rule.actions == 0) {
+        rule.actions = ACTION_LOG; // default
+      }
+
+      // Methods (for request rules) - from filter node
+      if (filter_node["methods"]) {
+        for (auto const &method_node : filter_node["methods"]) {
+          rule.methods.push_back(method_node.as<std::string>());
+        }
+      }
+
+      // Status codes (for response rules) - from filter node
+      if (filter_node["status"]) {
+        for (auto const &status_node : filter_node["status"]) {
+          rule.status_codes.push_back(status_node.as<int>());
+        }
+      }
+
+      // Validate method/status usage
+      if (rule.direction == Direction::REQUEST && !rule.status_codes.empty()) {
+        TSError("[%s] Rule '%s': 'status' is only valid for response rules", 
PLUGIN_NAME, rule.name.c_str());
+        delete config;
+        return nullptr;
+      }
+      if (rule.direction == Direction::RESPONSE && !rule.methods.empty()) {
+        TSError("[%s] Rule '%s': 'methods' is only valid for request rules", 
PLUGIN_NAME, rule.name.c_str());
+        delete config;
+        return nullptr;
+      }
+
+      // Max content length - from filter node
+      if (filter_node["max_content_length"]) {
+        rule.max_content_length = 
filter_node["max_content_length"].as<int64_t>();
+      }
+
+      // Header conditions - from filter node
+      if (filter_node["headers"]) {
+        for (auto const &header_node : filter_node["headers"]) {
+          HeaderCondition cond;
+          if (header_node["name"]) {
+            cond.name = header_node["name"].as<std::string>();
+          }
+          if (header_node["patterns"]) {
+            for (auto const &pattern_node : header_node["patterns"]) {
+              cond.patterns.push_back(pattern_node.as<std::string>());
+            }
+          }
+          rule.headers.push_back(cond);
+        }
+      }
+
+      // Body patterns - from filter node
+      if (filter_node["body_patterns"]) {
+        for (auto const &pattern_node : filter_node["body_patterns"]) {
+          std::string pattern = pattern_node.as<std::string>();
+          rule.body_patterns.push_back(pattern);
+          if (pattern.length() > rule.max_pattern_len) {
+            rule.max_pattern_len = pattern.length();
+          }
+        }
+      }
+
+      // Update max lookback
+      if (rule.max_pattern_len > 1) {
+        size_t lookback = rule.max_pattern_len - 1;
+        if (lookback > config->max_lookback) {
+          config->max_lookback = lookback;
+        }
+      }
+
+      // Create a metrics counter for this rule
+      std::string stat_name = std::string("plugin.") + PLUGIN_NAME + ".rule." 
+ rule.name + ".matches";
+      rule.stat_id          = TSStatCreate(stat_name.c_str(), 
TS_RECORDDATATYPE_INT, TS_STAT_NON_PERSISTENT, TS_STAT_SYNC_COUNT);
+      if (rule.stat_id == TS_ERROR) {
+        TSError("[%s] Failed to create stat '%s'", PLUGIN_NAME, 
stat_name.c_str());
+        delete config;
+        return nullptr;
+      }
+      Dbg(dbg_ctl, "Created stat '%s' with id %d", stat_name.c_str(), 
rule.stat_id);
+
+      Dbg(dbg_ctl, "Loaded rule: %s (direction=%s, actions=%u)", 
rule.name.c_str(),
+          rule.direction == Direction::REQUEST ? "request" : "response", 
rule.actions);
+
+      // Add to appropriate list
+      if (rule.direction == Direction::REQUEST) {
+        config->request_rules.push_back(std::move(rule));
+      } else {
+        config->response_rules.push_back(std::move(rule));
+      }
+    }
+  } catch (const std::exception &ex) {
+    TSError("[%s] Error parsing config: %s", PLUGIN_NAME, ex.what());
+    delete config;
+    return nullptr;
+  }
+
+  Dbg(dbg_ctl, "Loaded %zu request rules and %zu response rules 
(max_lookback=%zu)", config->request_rules.size(),
+      config->response_rules.size(), config->max_lookback);
+
+  return config;
+}
+
+} // anonymous namespace
+
+///////////////////////////////////////////////////////////////////////////////
+// Remap plugin interface
+///////////////////////////////////////////////////////////////////////////////
+
+TSReturnCode
+TSRemapInit(TSRemapInterface *api_info, char *errbuf, int errbuf_size)
+{
+  if (!api_info) {
+    TSstrlcpy(errbuf, "[TSRemapInit] Invalid TSRemapInterface argument", 
errbuf_size);
+    return TS_ERROR;
+  }
+
+  if (api_info->size < sizeof(TSRemapInterface)) {
+    TSstrlcpy(errbuf, "[TSRemapInit] Incorrect size of TSRemapInterface 
structure", errbuf_size);
+    return TS_ERROR;
+  }
+
+  Dbg(dbg_ctl, "filter_body remap plugin initialized");
+  return TS_SUCCESS;
+}
+
+TSReturnCode
+TSRemapNewInstance(int argc, char *argv[], void **instance, char *errbuf, int 
errbuf_size)
+{
+  if (argc < 3) {
+    TSstrlcpy(errbuf, "[TSRemapNewInstance] Missing configuration file 
argument", errbuf_size);
+    return TS_ERROR;
+  }
+
+  FilterConfig *config = parse_config(argv[2]);
+  if (config == nullptr) {
+    TSstrlcpy(errbuf, "[TSRemapNewInstance] Failed to parse configuration 
file", errbuf_size);
+    return TS_ERROR;
+  }
+
+  *instance = config;
+  return TS_SUCCESS;
+}
+
+void
+TSRemapDeleteInstance(void *instance)
+{
+  auto *config = static_cast<FilterConfig *>(instance);
+  delete config;
+}
+
+TSRemapStatus
+TSRemapDoRemap(void *instance, TSHttpTxn txnp, TSRemapRequestInfo *rri 
ATS_UNUSED)
+{
+  auto *config = static_cast<FilterConfig *>(instance);
+  if (config == nullptr) {
+    return TSREMAP_NO_REMAP;
+  }
+
+  // For request rules, check headers now (in TSRemapDoRemap, headers are 
already available)
+  if (!config->request_rules.empty()) {
+    TSMBuffer bufp;
+    TSMLoc    hdr_loc;
+
+    if (TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc) == TS_SUCCESS) {
+      std::vector<Rule const *> active_rules;
+
+      for (auto const &rule : config->request_rules) {
+        if (method_matches(rule, bufp, hdr_loc) && content_length_ok(rule, 
bufp, hdr_loc) && headers_match(rule, bufp, hdr_loc)) {
+          Dbg(dbg_ctl, "Request rule '%s' header conditions matched, will 
inspect body", rule.name.c_str());
+          active_rules.push_back(&rule);
+        }
+      }
+
+      TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+
+      if (!active_rules.empty()) {
+        TSVConn transform = create_transform(txnp, config, active_rules, 
Direction::REQUEST);
+        TSHttpTxnHookAdd(txnp, TS_HTTP_REQUEST_TRANSFORM_HOOK, transform);
+      }
+    }
+  }
+
+  // For response rules, add a hook to check when response headers arrive
+  if (!config->response_rules.empty()) {
+    TSCont contp = TSContCreate(response_handler, nullptr);
+    TSContDataSet(contp, config);
+    TSHttpTxnHookAdd(txnp, TS_HTTP_READ_RESPONSE_HDR_HOOK, contp);
+    // Add TXN_CLOSE_HOOK to clean up the continuation
+    TSHttpTxnHookAdd(txnp, TS_HTTP_TXN_CLOSE_HOOK, contp);
+  }
+
+  return TSREMAP_NO_REMAP;
+}
diff --git a/tests/gold_tests/autest-site/ats_replay.test.ext 
b/tests/gold_tests/autest-site/ats_replay.test.ext
index 3028ac9edf..63ee6c217f 100644
--- a/tests/gold_tests/autest-site/ats_replay.test.ext
+++ b/tests/gold_tests/autest-site/ats_replay.test.ext
@@ -162,6 +162,10 @@ def ATSReplayTest(obj, replay_file: str):
     process_config = server_config.get('process_config', {})
     server = tr.AddVerifierServerProcess(name, replay_file, **process_config)
 
+    # Set expected return code for server if specified.
+    if 'return_code' in server_config:
+        server.ReturnCode = server_config['return_code']
+
     # ATS configuration.
     if not 'ats' in autest_config:
         raise ValueError(f"Replay file {replay_file} does not contain 
'autest.ats' section")
@@ -179,6 +183,10 @@ def ATSReplayTest(obj, replay_file: str):
     client = tr.AddVerifierClientProcess(
         name, replay_file, http_ports=[ts.Variables.port], 
https_ports=https_ports, **process_config)
 
+    # Set expected return code if specified.
+    if 'return_code' in client_config:
+        client.ReturnCode = client_config['return_code']
+
     if dns:
         ts.StartBefore(dns)
     ts.StartBefore(server)
diff --git a/tests/gold_tests/autest-site/trafficserver.test.ext 
b/tests/gold_tests/autest-site/trafficserver.test.ext
index 4cc7a2704f..8cd39944f1 100755
--- a/tests/gold_tests/autest-site/trafficserver.test.ext
+++ b/tests/gold_tests/autest-site/trafficserver.test.ext
@@ -58,7 +58,8 @@ def MakeATSProcess(
         log_data=default_log_data,
         use_traffic_out=True,
         dump_runroot=True,
-        enable_proxy_protocol=False):
+        enable_proxy_protocol=False,
+        disable_log_checks=False):
     """Create a traffic server process.
 
     :param block_for_debug: if True, causes traffic_server to run with the
@@ -240,11 +241,12 @@ def MakeATSProcess(
         tmpname = os.path.join(log_dir, fname)
         p.Disk.File(tmpname, id='diags_log')
     # add this test back once we have network namespaces working again
-    p.Disk.diags_log.Content = Testers.ExcludesExpression("ERROR:", f"Diags 
log file {fname} should not contain errors")
-    p.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", f"Diags 
log file {fname} should not contain errors")
-    p.Disk.diags_log.Content += Testers.ExcludesExpression(
-        "Unrecognized configuration value",
-        f"Diags log file {fname} should not contain a warning about an 
unrecognized configuration")
+    if not disable_log_checks:
+        p.Disk.diags_log.Content = Testers.ExcludesExpression("ERROR:", 
f"Diags log file {fname} should not contain errors")
+        p.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", 
f"Diags log file {fname} should not contain errors")
+        p.Disk.diags_log.Content += Testers.ExcludesExpression(
+            "Unrecognized configuration value",
+            f"Diags log file {fname} should not contain a warning about an 
unrecognized configuration")
 
     # traffic.out
     fname = "traffic.out"
diff --git 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_block.yaml 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_block.yaml
new file mode 100644
index 0000000000..baf83838ec
--- /dev/null
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_block.yaml
@@ -0,0 +1,16 @@
+# Configuration for blocking requests with XXE patterns.
+rules:
+  - name: "xxe_request_block"
+    filter:
+      direction: request
+      methods: [POST]
+      headers:
+        - name: "Content-Type"
+          patterns:
+            - "application/xml"
+            - "text/xml"
+      body_patterns:
+        - "<!ENTITY"
+        - "SYSTEM"
+    action: [log, block]
+
diff --git 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_header.yaml
 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_header.yaml
new file mode 100644
index 0000000000..c8b1e1ece5
--- /dev/null
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_header.yaml
@@ -0,0 +1,17 @@
+# Configuration for adding header on request match.
+rules:
+  - name: "xxe_request_header"
+    filter:
+      direction: request
+      methods: [POST]
+      headers:
+        - name: "Content-Type"
+          patterns:
+            - "application/xml"
+      body_patterns:
+        - "<!ENTITY"
+    action:
+      - log
+      - add_header:
+          X-Security-Match: "<rule_name>"
+
diff --git 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_log.yaml 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_log.yaml
new file mode 100644
index 0000000000..47f42f6fd5
--- /dev/null
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_request_log.yaml
@@ -0,0 +1,16 @@
+# Configuration for logging requests with XXE patterns (no blocking).
+rules:
+  - name: "xxe_request_log"
+    filter:
+      direction: request
+      methods: [POST]
+      headers:
+        - name: "Content-Type"
+          patterns:
+            - "application/xml"
+            - "text/xml"
+      body_patterns:
+        - "<!ENTITY"
+        - "SYSTEM"
+    action: [log]
+
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
new file mode 100644
index 0000000000..2888a229f2
--- /dev/null
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_block.yaml
@@ -0,0 +1,16 @@
+# Configuration for blocking responses with sensitive data.
+rules:
+  - name: "sensitive_response_block"
+    filter:
+      direction: response
+      status: [200]
+      headers:
+        - name: "Content-Type"
+          patterns:
+            - "application/json"
+            - "text/html"
+      body_patterns:
+        - "SSN:"
+        - "password:"
+    action: [log, block]
+
diff --git 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_header.yaml
 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_header.yaml
new file mode 100644
index 0000000000..a28ba54974
--- /dev/null
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_header.yaml
@@ -0,0 +1,17 @@
+# Configuration for adding header on response match.
+rules:
+  - name: "sensitive_response_header"
+    filter:
+      direction: response
+      status: [200]
+      headers:
+        - name: "Content-Type"
+          patterns:
+            - "application/json"
+      body_patterns:
+        - "secret_data"
+    action:
+      - log
+      - add_header:
+          X-Data-Classification: "sensitive"
+          X-Rule-Name: "<rule_name>"
diff --git 
a/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_log.yaml 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_log.yaml
new file mode 100644
index 0000000000..ad588a2f0d
--- /dev/null
+++ 
b/tests/gold_tests/pluginTest/filter_body/config/filter_body_response_log.yaml
@@ -0,0 +1,16 @@
+# Configuration for logging sensitive data in responses (no blocking).
+rules:
+  - name: "sensitive_response_log"
+    filter:
+      direction: response
+      status: [200]
+      headers:
+        - name: "Content-Type"
+          patterns:
+            - "application/json"
+            - "text/html"
+      body_patterns:
+        - "SSN:"
+        - "password:"
+    action: [log]
+
diff --git a/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml 
b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
new file mode 100644
index 0000000000..1b70d6048a
--- /dev/null
+++ b/tests/gold_tests/pluginTest/filter_body/filter_body.replay.yaml
@@ -0,0 +1,365 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+#
+# Consolidated filter_body plugin tests.
+# Each remap rule tests a different configuration, differentiated by host 
header.
+#
+meta:
+  version: "1.0"
+
+autest:
+  description: 'Verify filter_body plugin for request/response body content 
filtering'
+
+  server:
+    name: 'server'
+
+  client:
+    name: 'client'
+    return_code: 1
+
+  ats:
+    name: 'ts'
+    process_config:
+      enable_cache: false
+      # The filtered requests produce an ERROR message, so we have to disable
+      # the default log checks of the trafficserver extension.
+      disable_log_checks: true
+
+    copy_to_config_dir:
+      - config
+
+    records_config:
+      proxy.config.diags.debug.enabled: 1
+      proxy.config.diags.debug.tags: 'filter_body'
+
+    remap_config:
+      # Request log only - pattern logged but request passes through
+      - from: http://request-log.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_request_log.yaml"
+
+      # Request block - request with XXE pattern is blocked
+      - from: http://request-block.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_request_block.yaml"
+
+      # Request header - request passes, header added to server request
+      - from: http://request-header.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_request_header.yaml"
+
+      # Request no match - header mismatch, no body inspection
+      - from: http://request-nomatch.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_request_block.yaml"
+
+      # Response log - detect sensitive data in responses
+      - from: http://response-log.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_response_log.yaml"
+
+      # Response header - add header when sensitive data detected
+      - from: http://response-header.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_response_header.yaml"
+
+      # Response block - block responses with sensitive data
+      - from: http://response-block.example.com/
+        to: http://127.0.0.1:{SERVER_HTTP_PORT}/
+        plugins:
+          - name: "filter_body.so"
+            args:
+              - "config/filter_body_response_block.yaml"
+
+    log_validation:
+      diags_log:
+        contains:
+          - expression: "Matched rule: xxe_request_log"
+            description: "Verify request log rule matched"
+          - expression: "Matched rule: xxe_request_block"
+            description: "Verify request block rule matched"
+          - expression: "Matched rule: xxe_request_header"
+            description: "Verify request header rule matched"
+          - expression: "Matched rule: sensitive_response_log"
+            description: "Verify response log rule matched"
+          - expression: "Matched rule: sensitive_response_header"
+            description: "Verify response header rule matched"
+          - expression: "Matched rule: sensitive_response_block"
+            description: "Verify response block rule matched"
+      traffic_out:
+        contains:
+          - expression: "Blocking request due to rule"
+            description: "Verify request 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.
+          #- expression: "Added header X-Data-Classification"
+          #  description: "Verify header was added for response"
+
+sessions:
+  #############################################################################
+  # Test 1: Request log only - pattern logged but request passes through
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "POST"
+          version: "1.1"
+          url: /api/data
+          headers:
+            fields:
+              - [Host, request-log.example.com]
+              - [Content-Type, "application/xml"]
+              - [Content-Length, 49]
+              - [uuid, request-log-test]
+          content:
+            data: '<?xml version="1.0"?><!ENTITY xxe SYSTEM "file:">'
+
+        proxy-request:
+          method: "POST"
+          url: /api/data
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Length, 2]
+          content:
+            data: "OK"
+
+        proxy-response:
+          status: 200
+
+  #############################################################################
+  # 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.
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "POST"
+          version: "1.1"
+          url: /api/data
+          headers:
+            fields:
+              - [Host, request-block.example.com]
+              - [Content-Type, "application/xml+plus_other_stuff"]
+              - [Content-Length, 49]
+              - [uuid, request-block-test]
+          content:
+            data: '<?xml version="1.0"?><!ENTITY xxe SYSTEM "file:">'
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Length, 2]
+          content:
+            data: "OK"
+
+        proxy-response:
+          status: 502
+
+  #############################################################################
+  # Test 3: Request header - request passes, header added to server request
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "POST"
+          version: "1.1"
+          url: /api/data
+          headers:
+            fields:
+              - [Host, request-header.example.com]
+              - [Content-Type, "application/xml"]
+              - [Content-Length, 24]
+              - [uuid, request-header-test]
+          content:
+            data: '<?xml?><!ENTITY test="">'
+
+        proxy-request:
+          method: "POST"
+          url: /api/data
+          # Note that only internal headers are added since the body is
+          # inspected after the headers are sent to the origin. So
+          # don't expect to see any external headers added.
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Length, 2]
+          content:
+            data: "OK"
+
+        proxy-response:
+          status: 200
+
+  #############################################################################
+  # Test 4: Request no match - header mismatch, no body inspection
+  # Uses block config but with wrong Content-Type, so no inspection occurs
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "POST"
+          version: "1.1"
+          url: /api/data
+          headers:
+            fields:
+              - [Host, request-nomatch.example.com]
+              - [Content-Type, "application/json"]
+              - [Content-Length, 49]
+              - [uuid, request-nomatch-test]
+          content:
+            data: '<?xml version="1.0"?><!ENTITY xxe SYSTEM "file:">'
+
+        proxy-request:
+          method: "POST"
+          url: /api/data
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Length, 2]
+          content:
+            data: "OK"
+
+        proxy-response:
+          status: 200
+
+  #############################################################################
+  # Test 5: Response log - detect sensitive data in responses
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "GET"
+          version: "1.1"
+          url: /api/user
+          headers:
+            fields:
+              - [Host, response-log.example.com]
+              - [uuid, response-log-test]
+
+        proxy-request:
+          method: "GET"
+          url: /api/user
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Type, "application/json"]
+              - [Content-Length, 36]
+          content:
+            data: '{"name": "John", "SSN: 123-45-6789"}'
+
+        proxy-response:
+          status: 200
+
+  #############################################################################
+  # Test 6: Response header - detect pattern and attempt header addition
+  # Note: Response header addition during transforms has timing limitations
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "GET"
+          version: "1.1"
+          url: /api/secret
+          headers:
+            fields:
+              - [Host, response-header.example.com]
+              - [uuid, response-header-test]
+
+        proxy-request:
+          method: "GET"
+          url: /api/secret
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Type, "application/json"]
+              - [Content-Length, 28]
+          content:
+            data: '{"data": "secret_data here"}'
+
+        proxy-response:
+          status: 200
+          # Note that only internal headers are added since the body is
+          # inspected after the headers are sent to the origin. So
+          # 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.
+  #############################################################################
+  - transactions:
+      - client-request:
+          method: "GET"
+          version: "1.1"
+          url: /api/blocked
+          headers:
+            fields:
+              - [Host, response-block.example.com]
+              - [uuid, response-block-test]
+
+        proxy-request:
+          method: "GET"
+          url: /api/blocked
+
+        server-response:
+          status: 200
+          reason: OK
+          headers:
+            fields:
+              - [Content-Type, "application/json"]
+              - [Content-Length, 36]
+          content:
+            data: '{"name": "John", "SSN: 123-45-6789"}'
+
+        # Note that blocking happens after the 200 response headers are sent.
+        proxy-response:
+          status: 200
diff --git a/tests/gold_tests/pluginTest/filter_body/filter_body.test.py 
b/tests/gold_tests/pluginTest/filter_body/filter_body.test.py
new file mode 100644
index 0000000000..12931b23c4
--- /dev/null
+++ b/tests/gold_tests/pluginTest/filter_body/filter_body.test.py
@@ -0,0 +1,24 @@
+'''
+Verify filter_body plugin for request/response body content filtering.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = 'Verify filter_body plugin for request/response body content 
filtering.'
+
+Test.SkipUnless(Condition.PluginExists('filter_body.so'))
+
+Test.ATSReplayTest(replay_file="filter_body.replay.yaml")

Reply via email to