Fix responses lost to decompression, add alpn_protocols to request() - #78
Conversation
Two problems found while building against real targets. A response that declared Content-Encoding but carried no body was thrown away entirely. gzip and brotli correctly report there's no stream to read, and the `?` turned that into a transport error, so the caller couldn't tell the host apart from an unreachable one. Since we ask for `gzip, deflate, br` on every request, any bodyless response carrying a Content-Encoding hit this: bodyless redirects, HEAD responses (which echo the entity headers of the GET they mirror), and 304s (which carry the headers a 200 would). Those last two are correct server behavior, not edge cases. `request_batch` and `request_batch_stream` were affected too, since they share `parse_response`. `max_body_size` made it worse in two ways. It truncated the compressed bytes mid-stream, and the resulting decode failure discarded the whole response, so the cap couldn't be used with compressed bodies at all. And it only ever bounded the bytes read off the wire, never the inflated result, so a 291KB response could expand to 300MB resident, well past the 10MiB default. So `decompress` now takes the cap and reads incrementally: an empty body is empty whatever the header claims, output is bounded at every layer, and a stream that breaks partway keeps what inflated instead of failing. More broadly, a body that won't decode no longer costs you the response. Whatever the reason, the status line and headers arrived cleanly and the raw bytes are what the server actually sent, so they're handed back undecoded with a note in the debug log. Discarding the response instead looks identical to an unreachable host from the caller's side, which throws away far more than a body we can't read. That matters most for the tool this is for: unexpected bytes aren't grounds for dropping evidence. `read_body` already bounds those bytes, so returning them can't exceed the cap. Content-Encoding is an ordered list, but only an exact match on the whole header value was recognized, so `gzip, br` and the `x-gzip` alias fell through and handed back a still-compressed body as if it were content. Those are now parsed as a list and undone in reverse. A coding we can't undo returns the body untouched, since decoding the layers beneath it would only produce nonsense. Note this narrows what `max_body_size` returns. A caller setting a small cap on a large compressed body used to receive the whole thing inflated and now gets it truncated to the cap, which is what the parameter says it does. Separately, `RequestConfig.alpn_protocols` already existed and the direct-connection path already honored it, but the pooled path hardcoded an h2-first offer and never looked at the config, and the pyo3 signature for `request()` didn't accept the parameter at all. So from Python there was no way to keep a request off HTTP/2. That matters when a server puts a connection-specific header in an HTTP/2 response: RFC 9113 8.2.2 forbids it, hyper kills the stream with PROTOCOL_ERROR, and the response is lost even though the same server answers cleanly over HTTP/1.1. The only workaround was passing `resolve_ip` to divert onto the direct path, which is a DNS-pinning parameter doing protocol selection. The h2-first default is unchanged, and different offers already can't share a pooled connection since `TlsKey` includes `alpn_protocols`. Also gitignores local agent settings and compiled extension modules. A build left at the repo root shadows the installed package, because pytest puts the rootdir on sys.path, so a stale one silently gets tested instead of what you built.
en0f
left a comment
There was a problem hiding this comment.
Comments:
1. deflate with a zlib wrapper now seems to return the compressed bytes as the body, silently.
decode_one only tries raw DEFLATE. The zlib-wrapped form is what IIS and several CDNs actually send for Content-Encoding: deflate, and blasthttp advertises deflate in its default Accept-Encoding. Raw inflate chokes on the 78 9c header, buf is empty, so decode_one errors, and the new parse_response fallback hands back the compressed bytes at status 200.
on pr78: status 200, content = b'x\x9c\xb3\xc9(...' (== zlib.compress(payload))
on dev: RuntimeError: deflate decompression failed: corrupt deflate stream
Before, you got an error. Now r.content is binary garbage with nothing to distinguish it from a real body. Response has no attribute that says whether decoding succeeded, so anything matching, hashing, or regexing bodies silently matches on compressed bytes. Fix is to try flate2::read::ZlibDecoder and fall back to DeflateDecoder.
2. Repeated Content-Encoding header lines. Only the first is decoded.
parse_response uses headers().get("content-encoding"), which returns the first value only. This is the same stacked-encoding case the PR set out to fix, just spelled differently:
Content-Encoding: gzip
Content-Encoding: gzip
body = gzip(gzip(payload))
→ status 200, content = gzip(payload) # one layer peeled, still compressed
get_all("content-encoding") joined with "," feeds straight into the new list parser.
3. alpn_protocols on request() is broken alongside resolve_ip / request_target
Those two options route to dispatch_direct, which discards the negotiated protocol (let (stream, cert_info, _alpn, peer_ip)) and unconditionally does http1::handshake. So forcing h2 negotiates h2 and then speaks HTTP/1.1 over it:
python
request("https://one.one.one.one/", resolve_ip="1.1.1.1", alpn_protocols=["h2"])
→ RuntimeError: dispatch_direct request failed: connection closed before message completed
same request without alpn_protocols → 403, fine
The new docstring at src/python.rs:864 says the default offers ["h2", "http/1.1"]. That's the pooled path only; connect_stream (hyper.rs:1001) defaults to http/1.1 alone, so the documented semantics of the new parameter are wrong for both direct-dispatch modes. Either reject a non-http/1.1 ALPN list on that path with a clear error, or dispatch h2 when it's negotiated.
- The new comment claims a bound that doesn't exist
hyper.rs:1306 — "read_body stops at max_body, so a body that hit the cap is one we cut ourselves, mid-stream." read_body does body.collect().await and truncates after buffering the whole thing. With max_body_size=1000 against a 40 MB response, the client read all 40,012,223 bytes into memory and then returned 985. cut_by_cap happens to still be labelled correctly, but in the decompress doc comment, that a cap is needed because a small response can inflate into an arbitrarily large allocation — applies just as much to the wire read, which is unbounded.
Four problems the review caught, plus one flag that makes the third fix safe to rely on. `Content-Encoding: deflate` is specified as a zlib stream (RFC 9110 8.4.1.2 points at RFC 1950) and only bare deflate (RFC 1951) was handled. IIS and several CDN fronts send the wrapped form, and we ask for deflate on every request, so a real body came back as compressed bytes with nothing to distinguish it from content. `decode_deflate` now picks a decoder from the two-byte zlib header and falls back to the other flavor when the first inflates nothing, since a raw stream can pass that check by coincidence. Which shape arrived goes in the debug log, because it says something about what is in front of the server. Repeated `Content-Encoding` lines mean the same thing as one comma-joined line in arrival order (RFC 9110 5.3), and an edge that compresses an already-compressed body adds its own line rather than editing the one below it. Only the first line was read, so one layer came off and the still-compressed remainder was returned as the body. Joining `get_all` feeds the list parser. The lines stay untouched in `headers`, so the front-end/back-end disagreement is still visible. Empty list elements are ignored now as well (RFC 9110 5.6.1.2), which the join can produce from a header line that carried no value. `dispatch_direct` discarded the negotiated protocol and always spoke HTTP/1.1, so `resolve_ip` with an h2 offer negotiated h2 and then sent HTTP/1.1 over it, which a server can only answer by hanging up. It now dispatches over whatever ALPN agreed on. `request_target` with an h2 offer is refused, with an error saying where to go instead: h2 carries the target in `:path`, built from the URI, so there is no request-line to override. The http/1.1-only default on that path stays, on stronger grounds than compatibility. The ALPN offer is part of the client's TLS fingerprint, so changing it would change how every request host_header and the SSRF paths have ever sent looks on the wire. `read_body` collected the whole body and then truncated it, so the new comment claiming it stopped at the cap was wrong and `max_body_size` never bounded the read at all: a 40MB response against a 1KB cap was buffered in full. It now stops asking for frames at the cap and reports whether it cut the body, which also replaces inferring truncation from `len() >= max_body`, a test that can't tell a cut body from one landing exactly on the cap. Stopping early abandons the response so the connection can't be reused, which is the better trade when the alternative is unbounded. Handing back an undecodable body instead of dropping the response is right, but it turned what used to be a loud error into a quiet wrong answer: `content` held compressed bytes with nothing saying so, and the debug log only prints at verbosity >= 1. `Response.decode_error` now carries the reason, so anything that hashes, matches or diffs bodies can tell encoded bytes from content before it treats them as evidence.
en0f
left a comment
There was a problem hiding this comment.
Quick comments:
1.) Duplicated Content-Encoding line, body encoded once, now comes back undecoded
Joining get_all("content-encoding") is spec-correct (RFC 9110 5.3), but it regresses the more common shape of the doubled header: a proxy that re-adds Content-Encoding: gzip in front of a backend that already set it, without re-compressing. Two lines now mean gzip,gzip; the outer peel succeeds, the inner one fails on plain HTML, and decompress returns the original bytes per Decoded::raw, so the caller gets compressed bytes plus a decode_error instead of the body.
I confirmed this against both commits with a local server sending the header twice and a singly-gzipped body:
base 8e2d395: content == PAYLOAD → True
head e466b35: content == still-gzipped body → True, decode_error: gzip decompression failed: invalid gzip header
The PR's own doubled-mismatch test encodes this as intended behavior (gzip + br, only gzip applied → raw + flagged), so it's a deliberate choice, but for the identical-coding case it trades a body that used to read fine for one that doesn't. Worth considering keeping the deepest successful peel (flagged) rather than the original bytes when at least one layer came off; that keeps the genuinely double-compressed case working and stops the misconfig case from losing the body.
2.) Also, please double check the CLA Assistant check that is failing.
Reading every `Content-Encoding` line rather than just the first fixed the
genuinely double-compressed body, and broke the other shape of the same
header. A proxy that re-adds `Content-Encoding: gzip` in front of a
backend that already set it, without compressing again, leaves two lines
over a singly-compressed body. Joined, that reads `gzip,gzip`: the first
peel gives the body, the second fails because plain HTML is not gzip, and
`decompress` was reverting to the bytes that arrived. So a response that
used to hand back a readable body handed back compressed bytes and a
`decode_error` instead.
A layer that won't come off underneath one that did now keeps the deepest
result. The alternative reading, a body really encoded that many times,
leaves a layer on and is indistinguishable from this one, so the response
is still flagged, and the reason says how far decoding got:
1 of 2 content-encoding layers came off: gzip decompression failed:
invalid gzip header
Nothing came off at all is unchanged and still returns exactly what
arrived, which is what `gzip, br` over a gzip-only body does, since
brotli is the outermost coding there.
`decode_error` on `Response` therefore no longer means "these are the
bytes as they arrived" on its own. It means the body is not what
`Content-Encoding` declared, and the reason distinguishes untouched bytes
from a stack only partly undone. Docs and the README say so.
|
Cla failure is transient github issue made an adjustment for the double gzip case |
#78 landed on dev, so the decompression and ALPN work now overlaps this branch in three places. CHANGELOG: both sides added a section. The cookie entries stay under Unreleased, above the 0.10.0 section dev shipped. `request()`'s docstring: both sides documented a new parameter in the same block. Both paragraphs stay, `redirect_cookies` first to match the order the parameters appear in the signature. `dispatch_direct`: dev made it dispatch over HTTP/2 when ALPN negotiates it, this branch gave `build_request` a chain-cookie argument. Both, with `None` passed on either protocol branch, since this path doesn't follow redirects and so never has a chain to take cookies from. Verified together: 206 lib tests, 10 redirect-cookie tests, 161 Python tests, clippy and fmt clean, plus a redirect chain that sets a cookie and carries a compressed body on both hops, which exercises the two features in one request.
Responses were being discarded over undecodable bodies
A response that declared
Content-Encodingbut carried no body was thrown away entirely. gzip and brotli correctly report there's no stream to read, and the?turned that into a transport error, so the caller couldn't tell the host apart from an unreachable one. We ask forgzip, deflate, bron every request, so any bodyless response carrying aContent-Encodinghit it: bodyless redirects,HEADresponses (which echo the entity headers of theGETthey mirror), and304s (which carry the headers a200would). The last two are required server behavior, not broken edges.request_batchandrequest_batch_streamwere affected too, since they shareparse_response.max_body_sizemade it worse twice over. It cut the compressed bytes mid-stream and the resulting decode failure discarded the whole response, so the cap was unusable with compressed bodies. And it only ever bounded what came off the wire, never the inflated result, so a 291KB response could expand to 300MB resident, 28x past the 10MiB default.decompressnow takes the cap and reads incrementally: an empty body is empty whatever the header claims, output is bounded at every layer, and a stream that breaks partway keeps what inflated.More broadly, a body that won't decode no longer costs you the response. Whatever the reason, the status line and headers arrived cleanly and the raw bytes are what the server actually sent, so they come back undecoded with a note in the debug log. Discarding the response looks identical to an unreachable host from the caller's side, which throws away much more than a body we can't read.
Content-Encodinglists and thex-gzipaliasOnly an exact match on the whole header value was recognized, so
gzip, brandx-gzipfell through and handed back a still-compressed body as if it were content. Those are now parsed as the ordered list they are and undone in reverse. A coding we can't undo returns the body untouched, since peeling the layers beneath it would only produce nonsense.alpn_protocolswas unreachable fromrequest()RequestConfig.alpn_protocolsalready existed and the direct-connection path already honored it, but the pooled path hardcoded an h2-first offer and never consulted the config, and the pyo3 signature didn't accept the parameter at all. So there was no way to keep a request off HTTP/2 from Python.That matters when a server puts a connection-specific header in an HTTP/2 response. RFC 9113 8.2.2 forbids it, hyper kills the stream with
PROTOCOL_ERROR, and the response is lost even though the same server answers cleanly over HTTP/1.1. The only workaround was passingresolve_ipto divert onto the direct path, which is a DNS-pinning parameter doing protocol selection.The h2-first default is unchanged, so this is additive. Different offers already can't share a pooled connection, since
TlsKeyincludesalpn_protocols.Behavior changes worth a look
max_body_sizenow caps the decompressed body. A caller setting a small cap on a large compressed body used to receive the whole thing inflated and now gets it truncated to the cap, which is what the parameter says it does.except RuntimeErrorfallbacks aroundrequest()will take a different branch.Also
Version bumped to 0.10.0, and compiled extension modules are gitignored. A build left at the repo root shadows the installed package, because pytest puts the rootdir on
sys.path, so a stale one silently gets tested instead of what you built.