From 599f7c752692c8cf531b49ed4defd37c4b816471 Mon Sep 17 00:00:00 2001 From: Thejesh Reddy <35212698+thejesh23@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:43:49 -0700 Subject: [PATCH] fix(proxy): recompute Content-Length when replaying a modified body (#816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_raw_request kept the Content-Length inherited from the captured request, so replaying a modified body (repeat_request) emitted a request whose declared length did not match the body — truncating the payload or stalling the target. Drop any inherited Content-Length (case-insensitively) and recompute it from the body actually being sent. Adds tests covering a lengthened body, an emptied body, and the no-inherited-header path. Fixes #814 Co-authored-by: thejesh23 --- strix/tools/proxy/caido_api.py | 7 ++++++- tests/test_proxy_client.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/strix/tools/proxy/caido_api.py b/strix/tools/proxy/caido_api.py index 277d5c48..6397169d 100644 --- a/strix/tools/proxy/caido_api.py +++ b/strix/tools/proxy/caido_api.py @@ -187,7 +187,12 @@ def build_raw_request( final_headers = {**headers} final_headers.setdefault("Host", parsed.netloc) final_headers.setdefault("User-Agent", "strix") - if body and "Content-Length" not in {k.title() for k in final_headers}: + # A Content-Length inherited from the captured request describes the ORIGINAL + # body; once the body is modified for replay it is stale. Drop any inherited + # value (case-insensitively) and recompute it from the body actually being + # sent, so the replayed request is never desynced (truncated / smuggled). + final_headers = {k: v for k, v in final_headers.items() if k.title() != "Content-Length"} + if body: final_headers["Content-Length"] = str(len(body.encode("utf-8"))) lines = [f"{method.upper()} {path} HTTP/1.1"] diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index 3b589459..0fb8771e 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -140,6 +140,43 @@ async def test_host_call_serializes_concurrent_calls() -> None: assert state["max"] == 1 +def _headers_named(raw: bytes, name: str) -> list[str]: + head = raw.decode("utf-8").split("\r\n\r\n", 1)[0] + return [ + line.split(":", 1)[1].strip() + for line in head.split("\r\n")[1:] + if line.split(":", 1)[0].strip().lower() == name.lower() + ] + + +def test_build_raw_request_recomputes_content_length_for_modified_body() -> None: + # The captured request declared Content-Length: 12 (original body); the + # replayed body is longer. The emitted request must carry exactly one + # Content-Length equal to the ACTUAL body length, or the target truncates + # the modified payload (or the connection desyncs). + body = '{"user":"a\' OR 1=1 -- injected long payload"}' + _conn, raw = caido_api.build_raw_request( + method="POST", + url="https://example.com/login", + headers={"content-length": "12", "Content-Type": "application/json"}, + body=body, + ) + sent_body = raw.decode("utf-8").split("\r\n\r\n", 1)[1] + assert sent_body == body + assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))] + + +def test_build_raw_request_drops_stale_content_length_for_empty_body() -> None: + # A body cleared to empty must not keep the inherited (non-zero) length. + _conn, raw = caido_api.build_raw_request( + method="POST", + url="https://example.com/x", + headers={"Content-Length": "12"}, + body="", + ) + assert _headers_named(raw, "Content-Length") == [] + + class _Ctx: def __init__(self, context: Any) -> None: self.context = context