Skip to content

apply cookies from redirect hops to the hops that follow - #75

Open
liquidsec wants to merge 10 commits into
devfrom
redirect-cookies
Open

apply cookies from redirect hops to the hops that follow#75
liquidsec wants to merge 10 commits into
devfrom
redirect-cookies

Conversation

@liquidsec

Copy link
Copy Markdown
Collaborator

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_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, 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 covers request(), request_batch(), request_batch_stream(), and download() without per-path plumbing. It does not apply to raw_connect() (no HTTP layer to read or build) or to requests using resolve_ip / request_target (that path doesn't follow redirects at all).

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 merge into a caller-supplied Cookie header rather than being sent as a second one.

Pass redirect_cookies=False, or --no-redirect-cookies on 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.

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
en0f self-requested a review August 17, 2026 15:38

@en0f en0f left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants