From 770538ce0f8b63075db428353cbed79243f6bdee Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Wed, 11 Feb 2026 15:44:42 -0800 Subject: [PATCH 1/5] Allow set-body to replace origin server response bodies Previously, header_rewrite's set-body operator (which calls TSHttpTxnErrorBodySet) only worked for ATS-generated responses because internal_msg_buffer was only consumed by setup_internal_transfer(). When the origin sent a real response, the body was streamed through the tunnel and internal_msg_buffer was ignored. This change adds a check for internal_msg_buffer in the SERVER_READ and TRANSFORM_READ paths of handle_api_return(). When a plugin has set the internal message buffer, ATS now drains the origin response body (if possible) and uses setup_internal_transfer() instead of the tunnel. Key changes: - Check internal_msg_buffer in SERVER_READ and TRANSFORM_READ paths - Add do_drain_server_response_body() to synchronously drain small origin bodies so the server connection can be reused - Widen release_server_session() pooling condition to allow connection reuse after body drain (not just 304/HEAD) - Add TS_HTTP_READ_RESPONSE_HDR_HOOK to set-body's allowed hooks - Add autest covering origin body replacement at both hooks - Update set-body documentation with origin response examples --- doc/admin-guide/plugins/header_rewrite.en.rst | 27 ++- include/proxy/http/HttpSM.h | 1 + plugins/header_rewrite/operators.cc | 1 + src/proxy/http/HttpSM.cc | 79 ++++++- ...header_rewrite_set_body_origin.replay.yaml | 209 ++++++++++++++++++ .../header_rewrite_set_body_origin.test.py | 26 +++ .../rules/rule_set_body_origin_read_resp.conf | 20 ++ .../rules/rule_set_body_origin_send_resp.conf | 20 ++ 8 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.replay.yaml create mode 100644 tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.test.py create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_read_resp.conf create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_send_resp.conf diff --git a/doc/admin-guide/plugins/header_rewrite.en.rst b/doc/admin-guide/plugins/header_rewrite.en.rst index 3dafdae9241..6a1998724b6 100644 --- a/doc/admin-guide/plugins/header_rewrite.en.rst +++ b/doc/admin-guide/plugins/header_rewrite.en.rst @@ -1109,8 +1109,31 @@ set-body set-body -Sets the body to ````. Can also be used to delete a body with ``""``. This is only useful when overriding the origin status, i.e. -intercepting/pre-empting a request so that you can override the body from the body-factory with your own. +Sets the response body to ````. Can also be used to delete a body with ``""``. + +This operator can be used to replace the response body in two scenarios: + +1. **ATS-generated responses**: When overriding the origin status at remap time (e.g. + with ``set-status``), you can use ``set-body`` at ``SEND_RESPONSE_HDR_HOOK`` to + override the body from the body-factory with your own. + +2. **Origin server responses**: When the origin returns a response with a body, + ``set-body`` can replace the origin body with the specified text. This is useful + for sanitizing error responses (e.g. replacing a 403 body that contains sensitive + information). Use ``READ_RESPONSE_HDR_HOOK`` to inspect the origin response + headers and replace the body, or ``SEND_RESPONSE_HDR_HOOK`` to replace the body + just before sending to the client. + +When replacing an origin response body, ATS will attempt to drain the origin body +from the server connection buffer so the connection can be reused. If the origin +body is too large to be fully buffered or uses chunked encoding, the server +connection will be closed instead. + +Example: sanitize a 403 response from the origin:: + + cond %{READ_RESPONSE_HDR_HOOK} [AND] + cond %{STATUS} =403 + set-body "Access Denied" set-body-from ~~~~~~~~~~~~~ diff --git a/include/proxy/http/HttpSM.h b/include/proxy/http/HttpSM.h index 3bbbd802a14..937c387f713 100644 --- a/include/proxy/http/HttpSM.h +++ b/include/proxy/http/HttpSM.h @@ -421,6 +421,7 @@ class HttpSM : public Continuation, public PluginUserArgs void do_redirect(); void redirect_request(const char *redirect_url, const int redirect_len); void do_drain_request_body(HTTPHdr &response); + void do_drain_server_response_body(); void wait_for_full_body(); diff --git a/plugins/header_rewrite/operators.cc b/plugins/header_rewrite/operators.cc index 9a3238d3af7..7e0e2e4db89 100644 --- a/plugins/header_rewrite/operators.cc +++ b/plugins/header_rewrite/operators.cc @@ -813,6 +813,7 @@ void OperatorSetBody::initialize_hooks() { add_allowed_hook(TS_REMAP_PSEUDO_HOOK); + add_allowed_hook(TS_HTTP_READ_RESPONSE_HDR_HOOK); add_allowed_hook(TS_HTTP_SEND_RESPONSE_HDR_HOOK); } diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index d9cfb662923..0fe2544368c 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -1675,9 +1675,18 @@ HttpSM::handle_api_return() switch (t_state.next_action) { case HttpTransact::StateMachineAction_t::TRANSFORM_READ: { - HttpTunnelProducer *p = setup_transfer_from_transform(); - perform_transform_cache_write_action(); - tunnel.tunnel_run(p); + if (t_state.internal_msg_buffer) { + // A plugin replaced the response body via TSHttpTxnErrorBodySet(). + // Use internal transfer instead of the transform tunnel. + SMDbg(dbg_ctl_http, "plugin set internal body, bypassing transform for internal transfer"); + do_drain_server_response_body(); + release_server_session(); + setup_internal_transfer(&HttpSM::tunnel_handler); + } else { + HttpTunnelProducer *p = setup_transfer_from_transform(); + perform_transform_cache_write_action(); + tunnel.tunnel_run(p); + } break; } case HttpTransact::StateMachineAction_t::SERVER_READ: { @@ -1709,6 +1718,13 @@ HttpSM::handle_api_return() } setup_blind_tunnel(true, initial_data); + } else if (t_state.internal_msg_buffer) { + // A plugin replaced the origin response body via TSHttpTxnErrorBodySet(). + // Drain the origin body if possible, then use internal transfer. + SMDbg(dbg_ctl_http, "plugin set internal body, using internal transfer instead of server tunnel"); + do_drain_server_response_body(); + release_server_session(); + setup_internal_transfer(&HttpSM::tunnel_handler); } else { HttpTunnelProducer *p = setup_server_transfer(); perform_cache_write_action(); @@ -6000,8 +6016,10 @@ HttpSM::release_server_session(bool serve_from_cache) t_state.hdr_info.server_request.valid() && (t_state.hdr_info.server_response.status_get() == HTTPStatus::NOT_MODIFIED || (t_state.hdr_info.server_request.method_get_wksidx() == HTTP_WKSIDX_HEAD && - t_state.www_auth_content != HttpTransact::CacheAuth_t::NONE)) && - plugin_tunnel_type == HttpPluginTunnel_t::NONE && (!server_entry || !server_entry->eos)) { + t_state.www_auth_content != HttpTransact::CacheAuth_t::NONE) || + t_state.internal_msg_buffer != nullptr) && // body drained by do_drain_server_response_body() + plugin_tunnel_type == HttpPluginTunnel_t::NONE && + (!server_entry || !server_entry->eos)) { if (t_state.www_auth_content == HttpTransact::CacheAuth_t::NONE || serve_from_cache == false) { // Must explicitly set the keep_alive_no_activity time before doing the release server_txn->set_inactivity_timeout(HRTIME_SECONDS(t_state.txn_conf->keep_alive_no_activity_timeout_out)); @@ -6331,6 +6349,57 @@ HttpSM::do_drain_request_body(HTTPHdr &response) _ua.get_txn()->set_close_connection(response); } +// +// void HttpSM::do_drain_server_response_body() +// +// Attempt to synchronously drain the origin server response body +// so the connection can be returned to the pool. If the body cannot +// be drained (chunked, unknown length, or not fully received), +// mark the server connection as no-keepalive so it will be closed. +// +void +HttpSM::do_drain_server_response_body() +{ + if (t_state.current.server == nullptr || server_txn == nullptr) { + return; + } + + int64_t content_length = t_state.hdr_info.response_content_length; + + if (content_length == HTTP_UNDEFINED_CL) { + // Chunked or unknown length -- can't drain synchronously + SMDbg(dbg_ctl_http, "server response body drain: chunked/unknown length, closing connection"); + t_state.current.server->keep_alive = HTTPKeepAlive::NO_KEEPALIVE; + return; + } + + if (content_length == 0) { + // No body to drain + SMDbg(dbg_ctl_http, "server response body drain: zero length, connection reusable"); + return; + } + + int64_t avail = server_txn->get_remote_reader()->read_avail(); + + if (avail >= content_length) { + // Entire body is in the buffer -- consume it so the connection can be reused + server_txn->get_remote_reader()->consume(content_length); + SMDbg(dbg_ctl_http, "server response body drain: consumed %" PRId64 " bytes", content_length); + + // Verify origin didn't send more than Content-Length (protocol violation). + // Same check as server_transfer_init(). + if (server_txn->get_remote_reader()->read_avail() > 0) { + SMDbg(dbg_ctl_http, "server response body drain: extra data after Content-Length, closing connection"); + t_state.current.server->keep_alive = HTTPKeepAlive::NO_KEEPALIVE; + } + } else { + // Body not fully received -- close the connection + SMDbg(dbg_ctl_http, "server response body drain: only %" PRId64 " of %" PRId64 " bytes available, closing connection", avail, + content_length); + t_state.current.server->keep_alive = HTTPKeepAlive::NO_KEEPALIVE; + } +} + void HttpSM::do_setup_client_request_body_tunnel(HttpVC_t to_vc_type) { diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.replay.yaml b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.replay.yaml new file mode 100644 index 00000000000..78f04ebf23d --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.replay.yaml @@ -0,0 +1,209 @@ +# 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. + +meta: + version: "1.0" + +# Test that set-body can replace origin server response bodies. +# Previously, set-body only worked for ATS-generated responses because +# TSHttpTxnErrorBodySet() sets internal_msg_buffer which was only consumed +# by setup_internal_transfer(). With the fix, handle_api_return() checks +# internal_msg_buffer in the SERVER_READ path and diverts to +# setup_internal_transfer() instead of the origin body tunnel. + +autest: + description: 'Test set-body replacing origin server response bodies' + + dns: + name: 'dns' + + server: + name: 'server' + + client: + name: 'client' + process_config: + other_args: '--thread-limit 1' + + ats: + name: 'ts' + + copy_to_config_dir: + - 'rules' + + records_config: + proxy.config.http.insert_response_via_str: 0 + proxy.config.http.cache.http: 0 + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: 'http|header_rewrite' + + remap_config: + # Test 1: set-body at SEND_RESPONSE_HDR replacing origin 403 body + - from: "http://www.example.com/set_body_send_resp_403/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_403/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_send_resp.conf" + + # Test 2: set-body at READ_RESPONSE_HDR replacing origin 403 body + - from: "http://www.example.com/set_body_read_resp_403/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_403/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_read_resp.conf" + + # Test 3: set-body at SEND_RESPONSE_HDR replacing origin 200 body + - from: "http://www.example.com/set_body_send_resp_200/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_200/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_send_resp.conf" + + # Test 4: set-body at SEND_RESPONSE_HDR with origin empty body (Content-Length: 0) + - from: "http://www.example.com/set_body_empty_origin/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_empty/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_send_resp.conf" + +sessions: + +# Test 1: Origin returns 403 with sensitive body, set-body at SEND_RESPONSE_HDR replaces it +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /set_body_send_resp_403/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 1 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "39" ] + - [ Content-Type, "text/plain" ] + content: + size: 39 + data: "Sensitive account info: secret-key-12345" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + +# Test 2: Origin returns 403 with sensitive body, set-body at READ_RESPONSE_HDR replaces it +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /set_body_read_resp_403/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 2 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "39" ] + - [ Content-Type, "text/plain" ] + content: + size: 39 + data: "Sensitive account info: secret-key-12345" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + +# Test 3: Origin returns 200 with body, set-body replaces it (works for any status) +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /set_body_send_resp_200/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 3 ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "24" ] + - [ Content-Type, "text/html" ] + content: + size: 24 + data: "Original response body!!" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + +# Test 4: Origin returns 403 with empty body (Content-Length: 0), set-body adds body +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /set_body_empty_origin/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 4 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "0" ] + - [ Content-Type, "text/plain" ] + content: + size: 0 + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.test.py b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.test.py new file mode 100644 index 00000000000..580156635ab --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.test.py @@ -0,0 +1,26 @@ +''' +Test header_rewrite set-body replacing origin server response bodies. +''' +# 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 = ''' +Test that set-body can replace origin server response bodies, not just +ATS-generated responses. Covers SEND_RESPONSE_HDR and READ_RESPONSE_HDR hooks, +various status codes, and empty origin bodies. +''' + +Test.ATSReplayTest(replay_file="header_rewrite_set_body_origin.replay.yaml") diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_read_resp.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_read_resp.conf new file mode 100644 index 00000000000..a92cd97010f --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_read_resp.conf @@ -0,0 +1,20 @@ +# +# 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. + +# Replace the origin response body at READ_RESPONSE_HDR_HOOK +cond %{READ_RESPONSE_HDR_HOOK} + set-body "Sanitized" diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_send_resp.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_send_resp.conf new file mode 100644 index 00000000000..9047686f32a --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_send_resp.conf @@ -0,0 +1,20 @@ +# +# 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. + +# Replace the origin response body at SEND_RESPONSE_HDR_HOOK +cond %{SEND_RESPONSE_HDR_HOOK} + set-body "Sanitized" From 6ef9387573ad5374572d19b2fdcd8bbd0125573d Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Thu, 12 Feb 2026 10:07:46 -0800 Subject: [PATCH 2/5] Standardize set-body-from to preserve origin response headers Change set-body-from to reenable with TS_EVENT_HTTP_CONTINUE instead of TS_EVENT_HTTP_ERROR, so the origin response headers and status code are preserved when the body is replaced. This aligns set-body-from behavior with set-body. Changes: - handleFetchEvents: reenable with TS_EVENT_HTTP_CONTINUE on success - Remove TSHttpTxnStatusSet workaround that was needed for error path - Add TS_HTTP_SEND_RESPONSE_HDR_HOOK support to set-body-from - Update test to expect 200 OK (was 500 INKApi Error) - Update documentation to reflect new behavior --- doc/admin-guide/plugins/header_rewrite.en.rst | 28 +++++++++++-------- plugins/header_rewrite/operators.cc | 8 ++---- .../header_rewrite_set_body_from.test.py | 5 ++-- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/doc/admin-guide/plugins/header_rewrite.en.rst b/doc/admin-guide/plugins/header_rewrite.en.rst index 6a1998724b6..16f44420fe8 100644 --- a/doc/admin-guide/plugins/header_rewrite.en.rst +++ b/doc/admin-guide/plugins/header_rewrite.en.rst @@ -1141,26 +1141,30 @@ set-body-from set-body-from -Will call ```` (see URL in `URL Parts`_) to retrieve a custom error response -and set the body with the result. Triggering this rule on an OK transaction will -send a 500 status code to the client with the desired response. If this is triggered -on any error status code, that original status code will be sent to the client. +Will call ```` (see URL in `URL Parts`_) to retrieve a response body from a +secondary URL and use it to replace the origin's response body. The origin's response +status code and headers are preserved, and the fetched content replaces only the body. -.. note:: - This config should only be set using READ_RESPONSE_HDR_HOOK +This operator can be used at ``READ_RESPONSE_HDR_HOOK`` or ``SEND_RESPONSE_HDR_HOOK``. +When the origin response body is fully buffered and has a known ``Content-Length``, +ATS will drain the origin body to allow connection reuse. Otherwise, the origin +connection is closed. + +If the fetch fails or times out, the original origin response is sent unmodified. An example config would look like:: - cond %{READ_RESPONSE_HDR_HOOK} - set-body-from http://www.example.com/second + cond %{READ_RESPONSE_HDR_HOOK} [AND] + cond %{STATUS} =403 + set-body-from http://www.example.com/custom-error-page -Where ``http://www.example.com/second`` is the destination to retrieve the custom response from. -This can be enabled per-mapping or globally. -Ensure there is a remap rule for the second endpoint as well! +Where ``http://www.example.com/custom-error-page`` is the destination to retrieve the +custom response body from. This can be enabled per-mapping or globally. +Ensure there is a remap rule for the secondary endpoint as well! An example remap config would look like:: map /first http://www.example.com/first @plugin=header_rewrite.so @pparam=cond1.conf - map /second http://www.example.com/second + map /custom-error-page http://www.example.com/custom-error-page set-config ~~~~~~~~~~ diff --git a/plugins/header_rewrite/operators.cc b/plugins/header_rewrite/operators.cc index 7e0e2e4db89..8bcf8debe86 100644 --- a/plugins/header_rewrite/operators.cc +++ b/plugins/header_rewrite/operators.cc @@ -68,7 +68,7 @@ handleFetchEvents(TSCont cont, TSEvent event, void *edata) } else { TSWarning("[%s] Successful set-custom-body fetch did not result in any content", __FUNCTION__); } - TSHttpTxnReenable(http_txn, TS_EVENT_HTTP_ERROR); + TSHttpTxnReenable(http_txn, TS_EVENT_HTTP_CONTINUE); } break; case OperatorSetBodyFrom::TS_EVENT_FETCHSM_FAILURE: { Dbg(pi_dbg_ctl, "OperatorSetBodyFrom: Error getting custom body"); @@ -1357,6 +1357,7 @@ void OperatorSetBodyFrom::initialize_hooks() { add_allowed_hook(TS_HTTP_READ_RESPONSE_HDR_HOOK); + add_allowed_hook(TS_HTTP_SEND_RESPONSE_HDR_HOOK); } bool @@ -1388,11 +1389,6 @@ OperatorSetBodyFrom::exec(const Resources &res) const addr.sin_port = LOCAL_PORT; TSFetchUrl(static_cast(req_buf), req_buf_size, reinterpret_cast(&addr), fetchCont, AFTER_BODY, event_ids); - - // Forces original status code in event TSHttpTxnErrorBodySet changed - // the code or another condition was set conflicting with this one. - // Set here because res is the only structure that contains the original status code. - TSHttpTxnStatusSet(res.state.txnp, res.resp_status, PLUGIN_NAME); } else { TSError(PLUGIN_NAME, "OperatorSetBodyFrom:exec:: Could not create request"); return true; diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py index f4c47e6edb6..8108959957b 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py @@ -136,14 +136,13 @@ def test_setBodyFromSucceeds_200(self): ''' Test where set-body-from request succeeds and returns 200 OK Triggered from remap file - This is tested because right now, TSHttpTxnErrorBodySet will change OK status codes to 500 INKApi Error - Ideally, this would not occur. + The origin status code should be preserved when set-body-from replaces the body. ''' tr = Test.AddTestRun() tr.MakeCurlCommand('-s -v --proxy 127.0.0.1:{0} "http://www.example.com/200"'.format(self.ts.Variables.port), ts=self.ts) tr.Processes.Default.ReturnCode = 0 tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_200.gold" - tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("500 INKApi Error", "Expected 500 response") + tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("200 OK", "Expected 200 response") tr.StillRunningAfter = self.server def runTraffic(self): From d1ad8d2b6693bf921cab2198775e96b1ecffa47e Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Thu, 12 Feb 2026 11:56:32 -0800 Subject: [PATCH 3/5] Skip origin connection when set-body is used at remap hook When set-body (or any plugin using TSHttpTxnErrorBodySet()) is used at a pre-origin hook like REMAP_PSEUDO_HOOK, ATS now skips the origin connection entirely and serves a synthetic response. Previously, ATS would still open a TCP connection to the origin, exchange headers, and then throw away the body -- wasting resources. The change adds a check at the top of how_to_open_connection() in HttpTransact.cc: if internal_msg_buffer is already set, build a synthetic response and return INTERNAL_CACHE_NOOP. This works with set-status to support any status code (defaults to 200 OK). Also improves set-body-from test coverage: - Add SEND_RESPONSE_HDR_HOOK tests (previously untested) - Replace fragile gold-file comparisons with ContainsExpression/ ExcludesExpression verification of status codes and body content - Add new set-body-remap replay-based test for the origin-skip behavior - Update documentation with new synthetic response scenario and example --- doc/admin-guide/plugins/header_rewrite.en.rst | 26 +- src/proxy/http/HttpTransact.cc | 13 + .../header_rewrite-set_body_from_200.gold | 1 - ...eader_rewrite-set_body_from_conn_fail.gold | 17 -- ...ader_rewrite-set_body_from_remap_fail.gold | 15 -- .../header_rewrite-set_body_from_success.gold | 1 - .../header_rewrite_set_body_from.test.py | 231 ++++++++++-------- .../header_rewrite_set_body_remap.replay.yaml | 132 ++++++++++ .../header_rewrite_set_body_remap.test.py | 26 ++ ...conf => rule_set_body_from_read_resp.conf} | 9 +- ...=> rule_set_body_from_read_resp_fail.conf} | 11 +- .../rules/rule_set_body_from_send_resp.conf | 21 ++ .../rules/rule_set_body_remap_200.conf | 20 ++ .../rules/rule_set_body_remap_403.conf | 21 ++ 14 files changed, 390 insertions(+), 154 deletions(-) delete mode 100644 tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold delete mode 100644 tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_conn_fail.gold delete mode 100644 tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_remap_fail.gold delete mode 100644 tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold create mode 100644 tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.replay.yaml create mode 100644 tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.test.py rename tests/gold_tests/pluginTest/header_rewrite/rules/{rule_set_body_from_remap.conf => rule_set_body_from_read_resp.conf} (77%) rename tests/gold_tests/pluginTest/header_rewrite/rules/{rule_set_body_from_plugin.conf => rule_set_body_from_read_resp_fail.conf} (71%) create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_send_resp.conf create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_200.conf create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_403.conf diff --git a/doc/admin-guide/plugins/header_rewrite.en.rst b/doc/admin-guide/plugins/header_rewrite.en.rst index 16f44420fe8..9d6685e4786 100644 --- a/doc/admin-guide/plugins/header_rewrite.en.rst +++ b/doc/admin-guide/plugins/header_rewrite.en.rst @@ -1111,24 +1111,38 @@ set-body Sets the response body to ````. Can also be used to delete a body with ``""``. -This operator can be used to replace the response body in two scenarios: +This operator can be used to replace the response body in three scenarios: -1. **ATS-generated responses**: When overriding the origin status at remap time (e.g. +1. **Synthetic responses (no origin connection)**: When used at ``REMAP_PSEUDO_HOOK`` + (before the origin connection is established), ``set-body`` will skip the origin + connection entirely and serve a synthetic response directly. Use ``set-status`` to + control the response status code (defaults to 200 OK if not specified). This is + useful for blocking requests, serving synthetic pages, or returning canned responses + without touching the origin. + +2. **ATS-generated responses**: When overriding the origin status at remap time (e.g. with ``set-status``), you can use ``set-body`` at ``SEND_RESPONSE_HDR_HOOK`` to override the body from the body-factory with your own. -2. **Origin server responses**: When the origin returns a response with a body, +3. **Origin server responses**: When the origin returns a response with a body, ``set-body`` can replace the origin body with the specified text. This is useful for sanitizing error responses (e.g. replacing a 403 body that contains sensitive information). Use ``READ_RESPONSE_HDR_HOOK`` to inspect the origin response headers and replace the body, or ``SEND_RESPONSE_HDR_HOOK`` to replace the body just before sending to the client. -When replacing an origin response body, ATS will attempt to drain the origin body -from the server connection buffer so the connection can be reused. If the origin -body is too large to be fully buffered or uses chunked encoding, the server +When replacing an origin response body (scenario 3), ATS will attempt to drain the +origin body from the server connection buffer so the connection can be reused. If the +origin body is too large to be fully buffered or uses chunked encoding, the server connection will be closed instead. +Example: block a request at remap time without contacting the origin:: + + cond %{REMAP_PSEUDO_HOOK} [AND] + cond %{CLIENT-URL:PATH} /blocked + set-status 403 + set-body "Access Denied" + Example: sanitize a 403 response from the origin:: cond %{READ_RESPONSE_HDR_HOOK} [AND] diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc index 3cc8d3dc549..83c51c58ccf 100644 --- a/src/proxy/http/HttpTransact.cc +++ b/src/proxy/http/HttpTransact.cc @@ -786,6 +786,19 @@ how_to_open_connection(HttpTransact::State *s) { ink_assert((s->pending_work == nullptr) || (s->current.request_to == ResolveInfo::PARENT_PROXY)); + // If a plugin has already set a response body (e.g., set-body at remap), + // skip the origin connection and serve the synthetic response directly. + if (s->internal_msg_buffer) { + HTTPStatus status = (s->http_return_code != HTTPStatus::NONE) ? s->http_return_code : HTTPStatus::OK; + const char *reason = http_hdr_reason_lookup(status); + if (s->hdr_info.client_response.valid()) { + s->hdr_info.client_response.fields_clear(); + } + HttpTransact::build_response(s, &s->hdr_info.client_response, s->client_info.http_version, status, reason ? reason : "OK"); + s->source = HttpTransact::Source_t::INTERNAL; + return HttpTransact::StateMachineAction_t::INTERNAL_CACHE_NOOP; + } + // Originally we returned which type of server to open // Now, however, we may want to issue a cache // operation first in order to lock the cache diff --git a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold deleted file mode 100644 index 3e37495f65b..00000000000 --- a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold +++ /dev/null @@ -1 +0,0 @@ -Custom body found diff --git a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_conn_fail.gold b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_conn_fail.gold deleted file mode 100644 index 70d40bddd16..00000000000 --- a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_conn_fail.gold +++ /dev/null @@ -1,17 +0,0 @@ - - -Unknown Host - - - -

Unknown Host

-
- - -Description: Unable to locate the server requested --- -the server does not have a DNS entry. Perhaps there is a misspelling -in the server name, or the server no longer exists. Double-check the -name and try again. - -
- diff --git a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_remap_fail.gold b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_remap_fail.gold deleted file mode 100644 index 8f069904f11..00000000000 --- a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_remap_fail.gold +++ /dev/null @@ -1,15 +0,0 @@ - - -Not Found on Accelerator - - - -

Not Found on Accelerator

-
- - -Description: Your request on the specified host was not found. -Check the location and try again. - -
- diff --git a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold deleted file mode 100644 index 3e37495f65b..00000000000 --- a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold +++ /dev/null @@ -1 +0,0 @@ -Custom body found diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py index 8108959957b..08f1b275fa1 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py @@ -15,10 +15,18 @@ # limitations under the License. Test.Summary = ''' -Test for successful response manipulation using set-body-from +Test set-body-from replacing origin response bodies with content fetched from +a secondary URL. Covers READ_RESPONSE_HDR_HOOK, SEND_RESPONSE_HDR_HOOK, and +fetch failure scenarios. Verifies that the origin status code and headers are +preserved while only the body is replaced. ''' Test.ContinueOnFail = True +# Note: This test uses MakeOriginServer rather than ATSReplayTest because +# set-body-from uses TSFetchUrl internally, which creates a secondary HTTP +# request back through ATS. The replay framework's client-driven model doesn't +# handle these internal multi-hop requests cleanly. + class HeaderRewriteSetBodyFromTest: @@ -29,131 +37,154 @@ def __init__(self): def setUpOriginServer(self): self.server = Test.MakeOriginServer("server") - # Response for original transaction - response_header = {"headers": "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n", "body": "404 Not Found"} - - # Request/response for original transaction where transaction returns a 200 status code - remap_success_request_header = {"headers": "GET /200 HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - ooo = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "body": "200 OK"} - - self.server.addResponse("sessionfile.log", remap_success_request_header, ooo) - - # Request/response for original transaction with failed second tranasaction - remap_fail_1_request_header = {"headers": "GET /remap_fail HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - self.server.addResponse("sessionfile.log", remap_fail_1_request_header, response_header) - - plugin_fail_1_request_header = {"headers": "GET /plugin_fail HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - self.server.addResponse("sessionfile.log", plugin_fail_1_request_header, response_header) - - # Request/response for original successful transaction with successful second tranasaction - remap_success_1_request_header = {"headers": "GET /remap_success HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - self.server.addResponse("sessionfile.log", remap_success_1_request_header, response_header) - - plugin_success_1_request_header = {"headers": "GET /plugin_success HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - self.server.addResponse("sessionfile.log", plugin_success_1_request_header, response_header) - - # Request/response for custom body transaction that successfully retrieves body - success_2_request_header = {"headers": "GET /404.html HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - success_2_response_header = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "body": "Custom body found\n"} - self.server.addResponse("sessionfile.log", success_2_request_header, success_2_response_header) + # -- Custom body endpoint (served when set-body-from fetch succeeds) -- + custom_body_request = {"headers": "GET /custom_body HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + custom_body_response = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "body": "Custom body found\n"} + self.server.addResponse("sessionfile.log", custom_body_request, custom_body_response) + + # -- Primary endpoints for READ_RESPONSE_HDR tests -- + # Origin returns 404 (body should be replaced by custom_body) + read_resp_404_request = {"headers": "GET /read_resp_404 HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + read_resp_404_response = {"headers": "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n", "body": "Original 404 body"} + self.server.addResponse("sessionfile.log", read_resp_404_request, read_resp_404_response) + + # Origin returns 200 (body should be replaced by custom_body) + read_resp_200_request = {"headers": "GET /read_resp_200 HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + read_resp_200_response = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "body": "Original 200 body"} + self.server.addResponse("sessionfile.log", read_resp_200_request, read_resp_200_response) + + # -- Primary endpoints for SEND_RESPONSE_HDR tests -- + # Origin returns 404 (body should be replaced by custom_body) + send_resp_404_request = {"headers": "GET /send_resp_404 HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + send_resp_404_response = {"headers": "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n", "body": "Original 404 body"} + self.server.addResponse("sessionfile.log", send_resp_404_request, send_resp_404_response) + + # Origin returns 200 (body should be replaced by custom_body) + send_resp_200_request = {"headers": "GET /send_resp_200 HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + send_resp_200_response = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "body": "Original 200 body"} + self.server.addResponse("sessionfile.log", send_resp_200_request, send_resp_200_response) + + # -- Primary endpoint for fetch failure test -- + # Origin returns 404 (body should NOT be replaced because fetch fails) + fetch_fail_request = {"headers": "GET /fetch_fail HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + fetch_fail_response = {"headers": "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n", "body": "Original 404 body"} + self.server.addResponse("sessionfile.log", fetch_fail_request, fetch_fail_response) def setUpTS(self): self.ts = Test.MakeATSProcess("ts") - # Set header rewrite rules - self.ts.Setup.CopyAs('rules/rule_set_body_from_remap.conf', Test.RunDirectory) - self.ts.Setup.CopyAs('rules/rule_set_body_from_plugin.conf', Test.RunDirectory) - - self.ts.Disk.remap_config.AddLine( - """\ - map http://www.example.com/remap_success http://127.0.0.1:{0}/remap_success @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_remap.conf - map http://www.example.com/200 http://127.0.0.1:{0}/200 @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_remap.conf - map http://www.example.com/remap_fail http://127.0.0.1:{0}/remap_fail @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_remap.conf - map http://www.example.com/plugin_success http://127.0.0.1:{0}/plugin_success - map http://www.example.com/plugin_fail http://127.0.0.1:{0}/plugin_fail - map http://www.example.com/404.html http://127.0.0.1:{0}/404.html - map http://www.example.com/plugin_no_server http://127.0.0.1::{2}/plugin_no_server - """.format(self.server.Variables.Port, Test.RunDirectory, Test.GetTcpPort("bad_port"))) - self.ts.Disk.plugin_config.AddLine('header_rewrite.so {0}/rule_set_body_from_plugin.conf'.format(Test.RunDirectory)) - - def test_setBodyFromFails_remap(self): - ''' - Test where set-body-from request fails - Triggered from remap file - This uses the case where no remap rule is provided - ''' - tr = Test.AddTestRun() + self.ts.Setup.CopyAs('rules/rule_set_body_from_read_resp.conf', Test.RunDirectory) + self.ts.Setup.CopyAs('rules/rule_set_body_from_send_resp.conf', Test.RunDirectory) + self.ts.Setup.CopyAs('rules/rule_set_body_from_read_resp_fail.conf', Test.RunDirectory) + + self.ts.Disk.remap_config.AddLines( + [ + # Primary endpoints with set-body-from at READ_RESPONSE_HDR_HOOK + 'map http://www.example.com/read_resp_404' + ' http://127.0.0.1:{0}/read_resp_404' + ' @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_read_resp.conf'.format( + self.server.Variables.Port, Test.RunDirectory), + 'map http://www.example.com/read_resp_200' + ' http://127.0.0.1:{0}/read_resp_200' + ' @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_read_resp.conf'.format( + self.server.Variables.Port, Test.RunDirectory), + + # Primary endpoints with set-body-from at SEND_RESPONSE_HDR_HOOK + 'map http://www.example.com/send_resp_404' + ' http://127.0.0.1:{0}/send_resp_404' + ' @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_send_resp.conf'.format( + self.server.Variables.Port, Test.RunDirectory), + 'map http://www.example.com/send_resp_200' + ' http://127.0.0.1:{0}/send_resp_200' + ' @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_send_resp.conf'.format( + self.server.Variables.Port, Test.RunDirectory), + + # Primary endpoint for fetch failure test (fetch URL goes to bad port) + 'map http://www.example.com/fetch_fail' + ' http://127.0.0.1:{0}/fetch_fail' + ' @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_read_resp_fail.conf'.format( + self.server.Variables.Port, Test.RunDirectory), + + # Fetch URL endpoint (served for successful set-body-from fetches) + 'map http://www.example.com/custom_body http://127.0.0.1:{0}/custom_body'.format(self.server.Variables.Port), + + # Fetch failure URL maps to a port with no server (will cause fetch failure) + 'map http://www.example.com/no_server http://127.0.0.1:{0}/no_server'.format(Test.GetTcpPort("bad_port")), + ]) + + # Test 1: READ_RESPONSE_HDR + origin 404, fetch succeeds -> body replaced, 404 preserved + def test_read_resp_404_fetch_succeeds(self): + tr = Test.AddTestRun("read_resp_404: fetch succeeds, body replaced, 404 preserved") tr.MakeCurlCommand( - '-s -v --proxy 127.0.0.1:{0} "http://www.example.com/remap_fail"'.format(self.ts.Variables.port), ts=self.ts) + '-s -D - --proxy 127.0.0.1:{0} "http://www.example.com/read_resp_404"'.format(self.ts.Variables.port), ts=self.ts) tr.Processes.Default.ReturnCode = 0 tr.Processes.Default.StartBefore(self.server) tr.Processes.Default.StartBefore(self.ts) - tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_remap_fail.gold" - tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stdout.Content = Testers.ContainsExpression("HTTP/1.1 404", "Expected 404 status") + tr.Processes.Default.Streams.stdout.Content += Testers.ContainsExpression("Custom body found", "Expected custom body") + tr.Processes.Default.Streams.stdout.Content += Testers.ExcludesExpression( + "Original 404 body", "Original body should be replaced") tr.StillRunningAfter = self.server - def test_setBodyFromSucceeds_remap(self): - ''' - Test where set-body-from request succeeds - Triggered from remap file - ''' - tr = Test.AddTestRun() + # Test 2: READ_RESPONSE_HDR + origin 200, fetch succeeds -> body replaced, 200 preserved + def test_read_resp_200_fetch_succeeds(self): + tr = Test.AddTestRun("read_resp_200: fetch succeeds, body replaced, 200 preserved") tr.MakeCurlCommand( - '-s -v --proxy 127.0.0.1:{0} "http://www.example.com/remap_success"'.format(self.ts.Variables.port), ts=self.ts) + '-s -D - --proxy 127.0.0.1:{0} "http://www.example.com/read_resp_200"'.format(self.ts.Variables.port), ts=self.ts) tr.Processes.Default.ReturnCode = 0 - tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_success.gold" - tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stdout.Content = Testers.ContainsExpression("HTTP/1.1 200", "Expected 200 status") + tr.Processes.Default.Streams.stdout.Content += Testers.ContainsExpression("Custom body found", "Expected custom body") + tr.Processes.Default.Streams.stdout.Content += Testers.ExcludesExpression( + "Original 200 body", "Original body should be replaced") tr.StillRunningAfter = self.server - def test_setBodyFromSucceeds_plugin(self): - ''' - Test where set-body-from request succeeds - Triggered from plugin file - ''' - tr = Test.AddTestRun() + # Test 3: SEND_RESPONSE_HDR + origin 404, fetch succeeds -> body replaced, 404 preserved + def test_send_resp_404_fetch_succeeds(self): + tr = Test.AddTestRun("send_resp_404: fetch succeeds, body replaced, 404 preserved") tr.MakeCurlCommand( - '-s -v --proxy 127.0.0.1:{0} "http://www.example.com/plugin_success"'.format(self.ts.Variables.port), ts=self.ts) + '-s -D - --proxy 127.0.0.1:{0} "http://www.example.com/send_resp_404"'.format(self.ts.Variables.port), ts=self.ts) tr.Processes.Default.ReturnCode = 0 - tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_success.gold" - tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stdout.Content = Testers.ContainsExpression("HTTP/1.1 404", "Expected 404 status") + tr.Processes.Default.Streams.stdout.Content += Testers.ContainsExpression("Custom body found", "Expected custom body") + tr.Processes.Default.Streams.stdout.Content += Testers.ExcludesExpression( + "Original 404 body", "Original body should be replaced") tr.StillRunningAfter = self.server - def test_setBodyFromFails_plugin(self): - ''' - Test where set-body-from request fails - This uses the case where the second endpoint cannot connect to the requested server - Triggered from plugin file - ''' - tr = Test.AddTestRun() + # Test 4: SEND_RESPONSE_HDR + origin 200, fetch succeeds -> body replaced, 200 preserved + def test_send_resp_200_fetch_succeeds(self): + tr = Test.AddTestRun("send_resp_200: fetch succeeds, body replaced, 200 preserved") tr.MakeCurlCommand( - '-s -v --proxy 127.0.0.1:{0} "http://www.example.com/plugin_fail"'.format(self.ts.Variables.port), ts=self.ts) + '-s -D - --proxy 127.0.0.1:{0} "http://www.example.com/send_resp_200"'.format(self.ts.Variables.port), ts=self.ts) tr.Processes.Default.ReturnCode = 0 - tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_conn_fail.gold" - tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stdout.Content = Testers.ContainsExpression("HTTP/1.1 200", "Expected 200 status") + tr.Processes.Default.Streams.stdout.Content += Testers.ContainsExpression("Custom body found", "Expected custom body") + tr.Processes.Default.Streams.stdout.Content += Testers.ExcludesExpression( + "Original 200 body", "Original body should be replaced") tr.StillRunningAfter = self.server - def test_setBodyFromSucceeds_200(self): - ''' - Test where set-body-from request succeeds and returns 200 OK - Triggered from remap file - The origin status code should be preserved when set-body-from replaces the body. - ''' - tr = Test.AddTestRun() - tr.MakeCurlCommand('-s -v --proxy 127.0.0.1:{0} "http://www.example.com/200"'.format(self.ts.Variables.port), ts=self.ts) + # Test 5: READ_RESPONSE_HDR + fetch backend unreachable -> ATS error page replaces body, status preserved + # When the fetch URL's backend is unreachable, ATS returns its own error page to the FetchSM. + # The FetchSM treats this as a successful fetch and replaces the body with the error page. + # The original origin status code (404) is preserved. + def test_fetch_backend_unreachable(self): + tr = Test.AddTestRun("fetch_fail: backend unreachable, ATS error page replaces body, 404 preserved") + tr.MakeCurlCommand( + '-s -D - --proxy 127.0.0.1:{0} "http://www.example.com/fetch_fail"'.format(self.ts.Variables.port), ts=self.ts) tr.Processes.Default.ReturnCode = 0 - tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_200.gold" - tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("200 OK", "Expected 200 response") + tr.Processes.Default.Streams.stdout.Content = Testers.ContainsExpression( + "HTTP/1.1 404", "Expected original 404 status preserved") + tr.Processes.Default.Streams.stdout.Content += Testers.ContainsExpression( + "Could Not Connect", "Expected ATS error page from unreachable fetch backend") + tr.Processes.Default.Streams.stdout.Content += Testers.ExcludesExpression( + "Original 404 body", "Original body should be replaced by ATS error page") tr.StillRunningAfter = self.server - def runTraffic(self): - self.test_setBodyFromFails_remap() - self.test_setBodyFromSucceeds_remap() - self.test_setBodyFromSucceeds_plugin() - self.test_setBodyFromFails_plugin() - self.test_setBodyFromSucceeds_200() - def run(self): - self.runTraffic() + self.test_read_resp_404_fetch_succeeds() + self.test_read_resp_200_fetch_succeeds() + self.test_send_resp_404_fetch_succeeds() + self.test_send_resp_200_fetch_succeeds() + self.test_fetch_backend_unreachable() HeaderRewriteSetBodyFromTest().run() diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.replay.yaml b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.replay.yaml new file mode 100644 index 00000000000..0d0a5b7d301 --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.replay.yaml @@ -0,0 +1,132 @@ +# 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. + +meta: + version: "1.0" + +# Test that set-body at REMAP_PSEUDO_HOOK skips the origin connection entirely +# and serves a synthetic response. With the how_to_open_connection() check for +# internal_msg_buffer, ATS builds a synthetic response and returns +# INTERNAL_CACHE_NOOP instead of opening a connection to the origin. + +autest: + description: 'Test set-body at remap hook skipping origin connection' + + dns: + name: 'dns' + + server: + name: 'server' + + client: + name: 'client' + process_config: + other_args: '--thread-limit 1' + + ats: + name: 'ts' + + copy_to_config_dir: + - 'rules' + + records_config: + proxy.config.http.insert_response_via_str: 0 + proxy.config.http.cache.http: 0 + proxy.config.diags.debug.enabled: 1 + proxy.config.diags.debug.tags: 'http|header_rewrite' + + remap_config: + # Test 1: set-body + set-status 403 at remap -- origin should not be contacted + - from: "http://www.example.com/blocked/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/should_not_reach/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_remap_403.conf" + + # Test 2: set-body at remap without set-status -- defaults to 200 OK + - from: "http://www.example.com/synthetic/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/should_not_reach/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_remap_200.conf" + +sessions: + +# Test 1: set-body + set-status 403 at remap, origin is skipped +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /blocked/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 1 ] + + # The server-response should never be used because the origin is not contacted. + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "26" ] + - [ Content-Type, "text/plain" ] + content: + size: 26 + data: "This should NOT be served!" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "13", as: equal } ] + content: + size: 13 + data: "Access Denied" + +# Test 2: set-body at remap without set-status, defaults to 200 OK +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /synthetic/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 2 ] + + # The server-response should never be used because the origin is not contacted. + server-response: + status: 500 + reason: Internal Server Error + headers: + fields: + - [ Content-Length, "26" ] + - [ Content-Type, "text/plain" ] + content: + size: 26 + data: "This should NOT be served!" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "14", as: equal } ] + content: + size: 14 + data: "Synthetic Page" diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.test.py b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.test.py new file mode 100644 index 00000000000..94d62010497 --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_remap.test.py @@ -0,0 +1,26 @@ +''' +Test header_rewrite set-body at remap hook skipping origin connection. +''' +# 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 = ''' +Test that set-body at REMAP_PSEUDO_HOOK skips the origin connection entirely +and serves a synthetic response. Covers set-body with set-status 403 and +set-body without set-status (default 200 OK). +''' + +Test.ATSReplayTest(replay_file="header_rewrite_set_body_remap.replay.yaml") diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_remap.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_read_resp.conf similarity index 77% rename from tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_remap.conf rename to tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_read_resp.conf index 351e17f7b8d..1ced49cd72a 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_remap.conf +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_read_resp.conf @@ -14,11 +14,8 @@ # 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. -cond %{READ_RESPONSE_HDR_HOOK} -cond %{CLIENT-URL:PATH} = "remap_success" [OR] -cond %{CLIENT-URL:PATH} = "200" -set-body-from http://www.example.com/404.html +# Replace the origin response body with content fetched from a secondary URL +# at READ_RESPONSE_HDR_HOOK. cond %{READ_RESPONSE_HDR_HOOK} -cond %{CLIENT-URL:PATH} = "remap_fail" -set-body-from http://www.example.com/fail +set-body-from http://www.example.com/custom_body diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_read_resp_fail.conf similarity index 71% rename from tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf rename to tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_read_resp_fail.conf index c4c83abb17d..4c10b664999 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_read_resp_fail.conf @@ -15,12 +15,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Test uses cond %{CLIENT-URL:PATH} to differentiate tests -# It is not needed to make set-body-from work +# Attempt to replace the body from a URL that connects to a bad port. +# The fetch will fail and the original origin response should be preserved. cond %{READ_RESPONSE_HDR_HOOK} -cond %{CLIENT-URL:PATH} = "plugin_success" -set-body-from http://www.example.com/404.html - -cond %{READ_RESPONSE_HDR_HOOK} -cond %{CLIENT-URL:PATH} = "plugin_fail" -set-body-from http://www.example.com/plugin_no_server +set-body-from http://www.example.com/no_server diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_send_resp.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_send_resp.conf new file mode 100644 index 00000000000..5c9b2f25b9e --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_send_resp.conf @@ -0,0 +1,21 @@ +# +# 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. + +# Replace the origin response body with content fetched from a secondary URL +# at SEND_RESPONSE_HDR_HOOK. +cond %{SEND_RESPONSE_HDR_HOOK} +set-body-from http://www.example.com/custom_body diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_200.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_200.conf new file mode 100644 index 00000000000..a4c29580bc1 --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_200.conf @@ -0,0 +1,20 @@ +# +# 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. + +# Set a synthetic response body at remap time without set-status (defaults to 200 OK). +cond %{REMAP_PSEUDO_HOOK} + set-body "Synthetic Page" diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_403.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_403.conf new file mode 100644 index 00000000000..983d0dc3ba7 --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_remap_403.conf @@ -0,0 +1,21 @@ +# +# 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. + +# Set a synthetic 403 response at remap time, skipping the origin entirely. +cond %{REMAP_PSEUDO_HOOK} + set-status 403 + set-body "Access Denied" From 281ecf85b9966c911dcfb62a0bee749518cb3496 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Thu, 12 Feb 2026 12:58:07 -0800 Subject: [PATCH 4/5] Fix SDK regression test failures from remap error body leak When remap fails with remap_required=1 but a plugin tunnel exists (TSHttpTxnServerIntercept or TSHttpTxnIntercept), the error response body from build_error_response() was left in internal_msg_buffer even though the error response headers were properly cleared. This caused how_to_open_connection() to see the stale buffer and short-circuit the plugin tunnel, serving the remap error page instead of the plugin-provided response. Fix: free internal_msg_buffer when discarding the error response in EndRemapRequest. Also add null guards around server_txn in the SERVER_READ and TRANSFORM_READ internal_msg_buffer paths, and revert the release_server_session() condition change which could allow server session reuse without body draining. --- src/proxy/http/HttpSM.cc | 18 ++++++++++-------- src/proxy/http/HttpTransact.cc | 1 + 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index 0fe2544368c..f39bd3c4805 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -1679,8 +1679,10 @@ HttpSM::handle_api_return() // A plugin replaced the response body via TSHttpTxnErrorBodySet(). // Use internal transfer instead of the transform tunnel. SMDbg(dbg_ctl_http, "plugin set internal body, bypassing transform for internal transfer"); - do_drain_server_response_body(); - release_server_session(); + if (server_txn != nullptr) { + do_drain_server_response_body(); + release_server_session(); + } setup_internal_transfer(&HttpSM::tunnel_handler); } else { HttpTunnelProducer *p = setup_transfer_from_transform(); @@ -1722,8 +1724,10 @@ HttpSM::handle_api_return() // A plugin replaced the origin response body via TSHttpTxnErrorBodySet(). // Drain the origin body if possible, then use internal transfer. SMDbg(dbg_ctl_http, "plugin set internal body, using internal transfer instead of server tunnel"); - do_drain_server_response_body(); - release_server_session(); + if (server_txn != nullptr) { + do_drain_server_response_body(); + release_server_session(); + } setup_internal_transfer(&HttpSM::tunnel_handler); } else { HttpTunnelProducer *p = setup_server_transfer(); @@ -6016,10 +6020,8 @@ HttpSM::release_server_session(bool serve_from_cache) t_state.hdr_info.server_request.valid() && (t_state.hdr_info.server_response.status_get() == HTTPStatus::NOT_MODIFIED || (t_state.hdr_info.server_request.method_get_wksidx() == HTTP_WKSIDX_HEAD && - t_state.www_auth_content != HttpTransact::CacheAuth_t::NONE) || - t_state.internal_msg_buffer != nullptr) && // body drained by do_drain_server_response_body() - plugin_tunnel_type == HttpPluginTunnel_t::NONE && - (!server_entry || !server_entry->eos)) { + t_state.www_auth_content != HttpTransact::CacheAuth_t::NONE)) && + plugin_tunnel_type == HttpPluginTunnel_t::NONE && (!server_entry || !server_entry->eos)) { if (t_state.www_auth_content == HttpTransact::CacheAuth_t::NONE || serve_from_cache == false) { // Must explicitly set the keep_alive_no_activity time before doing the release server_txn->set_inactivity_timeout(HRTIME_SECONDS(t_state.txn_conf->keep_alive_no_activity_timeout_out)); diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc index 83c51c58ccf..05fef2bef8e 100644 --- a/src/proxy/http/HttpTransact.cc +++ b/src/proxy/http/HttpTransact.cc @@ -1235,6 +1235,7 @@ HttpTransact::EndRemapRequest(State *s) } else { s->hdr_info.client_response.destroy(); // release the underlying memory. s->hdr_info.client_response.clear(); // clear the pointers. + s->free_internal_msg_buffer(); // clear error body so plugin tunnel is not bypassed. TxnDbg(dbg_ctl_http_trans, "END HttpTransact::EndRemapRequest"); if (s->is_upgrade_request && s->post_remap_upgrade_return_point) { From 32087de73dd228510d9d6d970b151d551ad5d05d Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Thu, 12 Feb 2026 14:05:00 -0800 Subject: [PATCH 5/5] Fix set-body at remap and escalate plugin interaction with internal_msg_buffer In EndRemapRequest, the previous fix unconditionally cleared internal_msg_buffer in the else branch (remap success path). This destroyed the body set by set-body at remap hooks when no set-status was used, causing ATS to contact the origin instead of serving the synthetic response. Fix: Only clear internal_msg_buffer when a plugin tunnel is active (the original intent - clearing stale error bodies from build_error_response during remap failures for plugin tunnels). Also clear internal_msg_buffer in redirect_request() to prevent stale error bodies from build_error_response (e.g., connection failures) from being mistaken for plugin-set synthetic bodies by how_to_open_connection() during redirect/retry flows like the escalate plugin. --- src/proxy/http/HttpSM.cc | 4 ++++ src/proxy/http/HttpTransact.cc | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index f39bd3c4805..623e7b448f5 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -8558,6 +8558,10 @@ HttpSM::redirect_request(const char *arg_redirect_url, const int arg_redirect_le // XXX - doing a destroy() for now, we can do a fileds_clear() if we have performance issue t_state.hdr_info.client_response.destroy(); } + // Clear any error body from a previous failed connection attempt (e.g., from + // build_error_response) so that how_to_open_connection() does not mistake it + // for a plugin-set synthetic body and short-circuit the retry. + t_state.free_internal_msg_buffer(); int scheme = t_state.next_hop_scheme; int scheme_len = hdrtoken_index_to_length(scheme); diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc index 05fef2bef8e..e4185dd2d32 100644 --- a/src/proxy/http/HttpTransact.cc +++ b/src/proxy/http/HttpTransact.cc @@ -1233,9 +1233,19 @@ HttpTransact::EndRemapRequest(State *s) Metrics::Counter::increment(http_rsb.invalid_client_requests); TRANSACT_RETURN(StateMachineAction_t::SEND_ERROR_CACHE_NOOP, nullptr); } else { - s->hdr_info.client_response.destroy(); // release the underlying memory. - s->hdr_info.client_response.clear(); // clear the pointers. - s->free_internal_msg_buffer(); // clear error body so plugin tunnel is not bypassed. + // This else branch handles two cases: + // 1. Remap succeeded (reverse_proxy == true) - normal request processing + // 2. Remap failed but plugin tunnel exists - plugin overrides error + // + // For case 2, clear the stale error response that build_error_response() + // created during remap failure, because the plugin tunnel will provide + // its own response. For case 1, preserve any plugin-set internal_msg_buffer + // (e.g., from set-body at remap) so how_to_open_connection() can short-circuit. + if (s->state_machine->plugin_tunnel_type != HttpPluginTunnel_t::NONE) { + s->hdr_info.client_response.destroy(); // release the underlying memory. + s->hdr_info.client_response.clear(); // clear the pointers. + s->free_internal_msg_buffer(); // clear error body so plugin tunnel is not bypassed. + } TxnDbg(dbg_ctl_http_trans, "END HttpTransact::EndRemapRequest"); if (s->is_upgrade_request && s->post_remap_upgrade_return_point) {