diff --git a/strix/tools/proxy/caido_api.py b/strix/tools/proxy/caido_api.py index 6397169d..86ac9752 100644 --- a/strix/tools/proxy/caido_api.py +++ b/strix/tools/proxy/caido_api.py @@ -167,6 +167,9 @@ async def get_request_with_client( return await client.request.get(request_id, opts) +_FRAMING_HEADERS = frozenset({"content-length", "transfer-encoding"}) + + def build_raw_request( *, method: str, @@ -187,11 +190,15 @@ def build_raw_request( final_headers = {**headers} final_headers.setdefault("Host", parsed.netloc) final_headers.setdefault("User-Agent", "strix") - # 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"} + # Framing headers inherited from the captured request describe the ORIGINAL + # body; once the body is modified for replay they are stale. We always send a + # plain (non-chunked) body with an explicit Content-Length, so drop any + # inherited Content-Length AND Transfer-Encoding (case-insensitively) and + # recompute the length from the body actually being sent. This keeps the two + # framing mechanisms from conflicting (RFC 7230 3.3.3: a leftover + # Transfer-Encoding would make the target ignore Content-Length and try to + # parse the body as chunked), so the replay is never desynced. + final_headers = {k: v for k, v in final_headers.items() if k.lower() not in _FRAMING_HEADERS} if body: final_headers["Content-Length"] = str(len(body.encode("utf-8"))) diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index 0fb8771e..da54af66 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -166,6 +166,22 @@ def test_build_raw_request_recomputes_content_length_for_modified_body() -> None assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))] +def test_build_raw_request_drops_transfer_encoding_for_modified_body() -> None: + body = '{"user":"updated"}' + _conn, raw = caido_api.build_raw_request( + method="POST", + url="https://example.com/login", + headers={ + "tRaNsFeR-EnCoDiNg": "chunked", + "Content-Length": "7", + "Content-Type": "application/json", + }, + body=body, + ) + assert _headers_named(raw, "Transfer-Encoding") == [] + 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(