Skip to content

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

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

apply cookies from redirect hops to the hops that follow#75
liquidsec wants to merge 8 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.
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.

1 participant