apply cookies from redirect hops to the hops that follow - #75
Conversation
When following redirects, a cookie set by one hop is now sent on the later hops of the same request, which is what a browser does. It's what lets a login or bot-check page resolve: those hand you a cookie along with the redirect, and the cookie has to be on the next request to count for anything. Without it you land back on the same page or loop. The jar is request-scoped. It's created in send_inner and dropped when the request returns, so nothing carries into the next request and two concurrent requests can never see each other's cookies. A batch of 500 URLs runs 500 independent jars, which keeps every result reproducible on its own. Every HTTP path shares send_inner, so request(), the batch and streaming-batch paths, and download() all get this. Cookie selection follows RFC 6265, which matters beyond correctness: a cookie with no Domain is host-only, a Domain that doesn't cover the host that set it is rejected, Path has to match on a segment boundary, and Secure cookies never go over plain HTTP. That's what stops a redirect from being used to walk a session cookie onto an unrelated host. Chain cookies are merged into a caller-supplied Cookie header rather than sent as a second one. Opt out with redirect_cookies=False or --no-redirect-cookies.
Every open PR has been failing Rust Tests at the clippy step, dependabot's
and ours alike, including PRs that touch nothing but a Python dev
dependency. The bumps aren't the cause.
CI installs whatever the latest stable is, with no pin. Stable moved to
1.97.1, whose clippy extended manual_filter to catch this in mock.rs:
files_obj.and_then(|f| if f.is_none() { None } else { Some(f) })
That code is unchanged and has been on dev for a while. It only started
failing because the lint is new and the workflow runs -D warnings, so a
fresh lint turns into a hard error everywhere at once. Locally we were on
1.95, which is why nobody saw it coming.
Rewritten as filter(|f| !f.is_none()), which is what clippy suggests and
is behavior-identical.
The pin is the actual fix for the class of problem. rust-toolchain.toml
rather than a workflow input, so local cargo resolves to the same version
CI uses and this gets caught before pushing instead of after. Keeping
-D warnings is fine once the version is pinned, since new lints then
arrive only when someone bumps it deliberately.
Verified with 1.97.1: fmt, clippy --all-targets --all-features --locked
-D warnings, and cargo test --locked all pass.
It was shipping in the packaged crate, overriding the toolchain for anyone building from source. Also fixes the comment typo.
en0f
left a comment
There was a problem hiding this comment.
Comments:
Domain accepts public suffixes → cookie crosses hosts (src/cookies.rs:134-144)
domain_matches is a pure suffix-plus-label-boundary check with no public-suffix list, and §5.3 step 5 (reject a Domain that is a public suffix) isn't implemented anywhere. So Domain=com sent from any *.com host passes step 6 and is stored with host_only: false.
Repro against the code as written:
hop 1 GET https://attacker.com/x
302 Location: https://victim.com/
Set-Cookie: session=forced; Domain=com; Path=/
hop 2 GET https://victim.com/ -> Cookie: session=forced
domain_matches("attacker.com", "com") → not equal, not an IP, "attacker.com".len() > 3, ends with com, byte at index 8 is . → true. Then on hop 2 domain_matches("victim.com", "com") → true again. Same for co.uk, github.io, s3.amazonaws.com, etc.
This is the exact property the PR body and the module doc claim to guarantee ("stops a redirect from being used to walk a session cookie onto an unrelated host" / "never sent to a host it doesn't belong to"). It doesn't hold. Two directions matter: an attacker-controlled hop forcing a cookie onto a real target, and a real target that sets an over-broad Domain (harmless in browsers because they reject it, so servers do ship this) having its session cookie handed to an attacker-controlled redirect target under the same suffix.
ip_host_cannot_widen_via_domain and domain_not_covering_the_setting_host_is_rejected both pass and neither covers this. a domain=com case would fail today.
Fix needs either a PSL dependency (publicsuffix/psl) or, if you don't want the dep, a floor: reject a Domain attribute with no dot, and reject when the attribute has fewer labels than a hardcoded set of common two-label suffixes.
Merging chain cookies into a caller-supplied `Cookie` header appended without looking at names, so a site that resets a cookie the caller had pinned produced `Cookie: session=OLD; session=NEW` on the next hop. Which value the target reads is then up to its stack: Express keeps the first, PHP and Django keep the last. Same request, different session depending on what the site is written in. A header the caller wrote is a header we send, so their value wins, and it goes out once. The jar records their cookie names when the request starts and refuses to store a `Set-Cookie` naming one of them. Doing it at store time is what makes the rule hold everywhere in one place: the chain can't replace the value, an expiry can't delete it, and the two can't go out together as a duplicate pair. Names compare case-sensitively (RFC 6265 4.1.1), and every `Cookie` header the caller supplied counts, since the server sees all of them. Cookies the chain sets under other names are unaffected and still merge in behind the caller's. `store` now hands back the names it refused so the chain logs them. A site trying to overwrite a cookie you pinned is worth seeing rather than silently dropping. Documented on the `redirect_cookies` field, on `should_forward_redirect_cookies`, in the `cookies` module docs, in `request()`'s docstring (which said nothing about `redirect_cookies` before), and in the README section, whose old note only described the ordering. Also narrowed the README's claim that the RFC 6265 scoping stops a cookie being walked onto another host. That holds for cookies the chain picked up. Headers the caller supplies are sent as given on every hop, cross-host redirects included, which is the point of setting one.
A response may carry as many `Set-Cookie` headers as it likes, and every hop after it carried all of them. 90 cookies of 2KB in one redirect, which is a legal response and well under hyper's header limit, made the next request send a 184,848 byte `Cookie` header, with each further hop free to add more. Against hosts we don't trust, which is the normal case, that let the target decide how much we hold and how much we send. Three ceilings, with numbers taken from what clients and servers already do rather than picked: 4096 bytes per cookie and 50 cookies, which RFC 6265 6.1 asks a client to support and which is roughly where browsers sit, so a chain needing more is not a login flow; and 8KB across the chain, counting the separators, because past that the next server rejects the header line anyway (nginx's `large_client_header_buffers`, Apache's `LimitRequestFieldSize`), so growing further would only mean sending traffic that can't be answered. Whatever gets there first keeps the room. A chain's early cookies are the ones a login flow needs, so what gets dropped is whatever a later hop piles on top. A reset frees what the old value held, so a site updating one cookie every hop never fills the budget. The same flood now leaves the second hop carrying 6171 bytes, and what was dropped goes in the debug log rather than being swallowed, since an invisible cap reads as full coverage. The caller's own cookies aren't subject to any of this: their header is theirs. `CookieJar` is now `ChainCookies`. Everywhere else that word means storage attached to a session that outlives a request, expires cookies on a clock, and is shared. This is a scratch buffer for one redirect chain that gets thrown away when the request returns, and the old name had the docs explaining that it wasn't the thing it was named after. Renaming now costs nothing since the type has never shipped.
When following redirects, a cookie set by one hop is now sent on the later hops of the same request, the way a browser does.
This is what lets a login or bot-check page resolve. Those hand you a cookie along with the redirect, and the cookie has to be on the next request to count for anything. Without it you land back on the same page, or loop. It's a coverage fix: on those targets we were reporting whatever the redirect bounced us to instead of the actual response.
The jar is request-scoped. It's created in
send_innerand dropped when the request returns, so nothing carries into the next request and two concurrent requests can never see each other's cookies. A batch of 500 URLs runs 500 independent jars, so every result stays reproducible on its own. This is deliberately narrower than httpx and requests, where redirect forwarding and cross-request persistence are the same client-wide jar and you can't have one without the other.Every HTTP path funnels through
send_inner, so this coversrequest(),request_batch(),request_batch_stream(), anddownload()without per-path plumbing. It does not apply toraw_connect()(no HTTP layer to read or build) or to requests usingresolve_ip/request_target(that path doesn't follow redirects at all).Cookie selection follows RFC 6265, which matters beyond correctness. A cookie with no
Domainis host-only, aDomainthat doesn't cover the host that set it is rejected,Pathhas to match on a segment boundary, andSecurecookies never go over plain HTTP. That's what stops a redirect from being used to walk a session cookie onto an unrelated host. Chain cookies merge into a caller-suppliedCookieheader rather than being sent as a second one.Pass
redirect_cookies=False, or--no-redirect-cookieson the CLI, to revert to the previous behavior.Covered by 17 unit tests on the jar plus 7 end-to-end tests that drive a real server through a 302 and read the second hop off the socket.
Heads up for anyone diffing scan output: results will move on targets that set a cookie and redirect. That's the fix working.