Skip to content

Commit 27a17f0

Browse files
authored
Merge pull request #75 from blacklanternsecurity/redirect-cookies
apply cookies from redirect hops to the hops that follow
2 parents 8154204 + 44b26ef commit 27a17f0

11 files changed

Lines changed: 1528 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Cookies set on a redirect hop are applied to the hops that follow it, the way a browser does. What a chain collects lives for that one request, so nothing carries between requests
6+
- What a chain will hold is capped (4096 bytes per cookie, 50 cookies, 8KB total), so a response that sets hundreds of large cookies can't grow every later hop's `Cookie` header without bound
7+
- A `Domain` attribute is checked against the Public Suffix List, so `Domain=com`, `Domain=co.uk` or `Domain=github.io` can't be used to carry a cookie onto an unrelated host, and a cookie may only widen within one registrable domain
8+
- A cookie set in the request's own `Cookie` header always wins: every hop sends it, and a `Set-Cookie` naming it is ignored rather than replacing it, deleting it, or being sent alongside it
9+
- `redirect_cookies=False` (or `--no-redirect-cookies`) reverts to the previous behavior
10+
311
## 0.10.0
412

513
- Fix responses being discarded when `Content-Encoding` is declared on an empty body (bodyless redirects, `HEAD`, `304`)

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ http = "1"
4646
flate2 = "1"
4747
brotli = "8"
4848

49+
# Public Suffix List — decides where a cookie's `Domain` stops being allowed
50+
# to widen. Without it `Domain=com` or `Domain=github.io` is accepted and the
51+
# cookie crosses to an unrelated host. The list is compiled in, so this costs
52+
# no network and no runtime lookup; dependabot keeps the version, and with it
53+
# the list, current.
54+
psl = "2.1.224"
55+
4956
# Hashing — response fingerprinting (matches BBOT's hash format)
5057
murmur3 = "0.5"
5158

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Output is JSON (one object per response), including status, headers, redirect ch
109109
| `--rate-limit` | Requests per second (batch mode) | unlimited |
110110
| `-L, --follow-redirects` | Follow redirects | off |
111111
| `--max-redirects` | Max redirect hops | `10` |
112+
| `--no-redirect-cookies` | Don't apply cookies a redirect sets to later hops | off |
112113
| `-t, --timeout` | Request timeout (seconds) | `10` |
113114
| `--max-body-size` | Max response body (bytes) | 10 MB |
114115
| `--verify` | Enable TLS cert validation | off |
@@ -232,6 +233,7 @@ All parameters except `url` are optional:
232233
| `timeout` | `int` | Request timeout in seconds |
233234
| `follow_redirects` | `bool` | Follow redirects |
234235
| `max_redirects` | `int` | Max redirect hops |
236+
| `redirect_cookies` | `bool` | Apply cookies a redirect sets to later hops (default `True`) |
235237
| `verify_certs` | `bool` | Enable TLS cert validation (default `False`) |
236238
| `proxy` | `str` | HTTP/SOCKS proxy URL |
237239
| `no_proxy` | `list[str]` | Hosts that bypass the proxy |
@@ -319,6 +321,40 @@ response = await client.request(
319321
)
320322
```
321323

324+
### Cookies across redirects
325+
326+
When `follow_redirects` is on, a cookie set by one hop is sent on the hops that follow it, the same way a browser does. That's what makes a login or bot-check page work: it hands you a cookie along with the redirect, and the cookie has to be on the next request to count for anything. Without this you'd loop or land back on the same page.
327+
328+
This is **not a session**, and there's no cookie storage behind it. What a chain collects is created when the request starts and dropped when it returns, so nothing carries into the next request and no two requests can see each other's cookies. A batch of 500 URLs runs 500 independent chains, which keeps every result reproducible on its own.
329+
330+
What a chain will hold is capped, since a response can set as many cookies as it likes and every later hop would carry all of them: 4096 bytes per cookie and 50 cookies, which is what RFC 6265 asks a client to support and roughly what browsers allow, and 8KB across the whole chain, which is about where servers stop accepting a header line. Past those, later cookies are dropped and the ones already held are kept, with a line in the debug log saying what went.
331+
332+
Which cookie goes to which hop follows the usual rules (RFC 6265): a cookie with no `Domain` goes back only to the exact host that set it, a `Domain` that doesn't cover the host that sent it is thrown out, `Path` has to match, and `Secure` cookies never go over plain HTTP.
333+
334+
`Domain` also gets checked against the Public Suffix List, because the rules above don't cover it on their own: `Domain=com` does cover the host that set it, so it passes every other check, and then covers every other `.com` the chain visits. A cookie may only widen within one registrable domain, so `auth.example.com` can hand one to `app.example.com`, while `Domain=com`, `Domain=co.uk`, `Domain=github.io` and `Domain=s3.amazonaws.com` are refused. That is what stops a redirect walking a cookie the chain picked up onto an unrelated host. Headers you supply yourself are a different matter: those are sent as given on every hop, including after a redirect to another host, because a header you set is a header we send.
335+
336+
Pass `redirect_cookies=False` (or `--no-redirect-cookies` on the CLI) to turn it off and send only your own headers on every hop.
337+
338+
```python
339+
# On by default.
340+
r = await client.request("https://example.com/login", method="POST",
341+
body="user=x&pass=y", follow_redirects=True)
342+
343+
# Off: every hop gets only the headers you supplied.
344+
r = await client.request("https://example.com/login", follow_redirects=True,
345+
redirect_cookies=False)
346+
```
347+
348+
**A cookie you set yourself always wins.** If your request carries `Cookie: session=mine`, every hop of that chain sends `session=mine`. A `Set-Cookie` naming a cookie you set is ignored: it can't replace your value, an expiry on it can't delete your value, and the two never go out together as a duplicate pair. Sites reset cookies mid-redirect routinely, and servers disagree about which value to read when a name appears twice (some take the first, some the last), so the only rule that behaves the same everywhere is that what you wrote is what lands on the wire. Cookies the chain sets under *other* names are merged into your `Cookie` header, yours first.
349+
350+
```python
351+
# Every hop sends session=mine, whatever the site tries to set.
352+
r = await client.request("https://example.com/start", follow_redirects=True,
353+
headers=[("Cookie", "session=mine")])
354+
```
355+
356+
Run with `-v` to see it happen: the debug log records both the cookies each hop is given and any `Set-Cookie` that lost to one of yours.
357+
322358
### Proxy exclusions (`no_proxy`)
323359

324360
`proxy` routes a request through an HTTP or SOCKS5 proxy; `no_proxy` is a per-request list of hosts that bypass it and connect directly — the `NO_PROXY` equivalent. It's accepted by `request()`, `download()`, `raw_connect()`, and `BatchConfig`, and as the repeatable `--no-proxy` CLI flag.

src/client/hyper.rs

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -769,11 +769,12 @@ async fn dispatch_request(
769769
uri: &http::Uri,
770770
config: &RequestConfig,
771771
log: &DebugLog,
772+
redirect_cookies: Option<&str>,
772773
) -> Result<SingleResponse, ClientError> {
773774
// The pooled high-level client populates Host / :authority from the URI
774775
// itself, so we don't add a Host header here. Adding it would cause
775776
// duplicate :authority + host in the HTTP/2 HPACK block.
776-
let request = build_request(uri, config, false, false)?;
777+
let request = build_request(uri, config, false, false, redirect_cookies)?;
777778
let v = config.verbosity;
778779

779780
debug_record(log, v, 1, " Request headers:");
@@ -1124,10 +1125,10 @@ async fn dispatch_direct(
11241125
// :authority and :path, and a Host header of our own would duplicate
11251126
// :authority in the HPACK block.
11261127
let request = if h2 {
1127-
build_request(&request_uri, config, false, false)?
1128+
build_request(&request_uri, config, false, false, None)?
11281129
} else {
11291130
let use_origin_form = config.request_target.is_none();
1130-
build_request(&request_uri, config, use_origin_form, true)?
1131+
build_request(&request_uri, config, use_origin_form, true, None)?
11311132
};
11321133

11331134
debug_record(log, v, 1, " Request headers:");
@@ -1191,6 +1192,7 @@ async fn dispatch_forward_proxy(
11911192
target_uri: &http::Uri,
11921193
config: &RequestConfig,
11931194
log: &DebugLog,
1195+
redirect_cookies: Option<&str>,
11941196
) -> Result<SingleResponse, ClientError> {
11951197
let proxy_uri: http::Uri = proxy_url.parse().map_err(|e: http::uri::InvalidUri| {
11961198
ClientError::invalid_url(format!("invalid proxy URL: {}", e))
@@ -1228,7 +1230,7 @@ async fn dispatch_forward_proxy(
12281230

12291231
// Build request with absolute-form URI (SendRequest does NOT normalize it).
12301232
// The low-level http1 sender doesn't auto-populate Host, so add it manually.
1231-
let request = build_request(target_uri, config, false, true)?;
1233+
let request = build_request(target_uri, config, false, true, redirect_cookies)?;
12321234
let v = config.verbosity;
12331235

12341236
debug_record(log, v, 1, " Request headers:");
@@ -1255,6 +1257,11 @@ fn build_request(
12551257
config: &RequestConfig,
12561258
origin_form: bool,
12571259
manual_host_header: bool,
1260+
// Cookies picked up from earlier hops of this redirect chain, already
1261+
// filtered down to the ones that apply to `uri` and to names the caller
1262+
// didn't set themselves. Merged into the caller's own `Cookie` header
1263+
// when there is one, so we never send two.
1264+
redirect_cookies: Option<&str>,
12581265
) -> Result<hyper::Request<FullBody>, ClientError> {
12591266
// For direct connections (dispatch_direct), use origin-form (path + query only)
12601267
// in the request-line per RFC 7230 §5.3.1. For pooled/client connections,
@@ -1312,11 +1319,35 @@ fn build_request(
13121319
builder = builder.header("Accept-Encoding", "gzip, deflate, br");
13131320
}
13141321

1322+
// Emit the caller's headers, folding any redirect-chain cookies into the
1323+
// first `Cookie` header they supplied. If they supplied none, the
1324+
// chain's cookies go out as their own header after the caller's.
1325+
//
1326+
// The chain's list can't contain a name the caller set, because
1327+
// `ChainCookies` refuses to store one, so this concatenation never
1328+
// produces the same
1329+
// name twice. That matters: duplicate names are read inconsistently
1330+
// across servers (some take the first, some the last), so a request
1331+
// carrying both would behave differently depending on the target.
1332+
let mut merged_cookies = false;
13151333
if let Some(ref custom_headers) = config.headers {
13161334
for (name, value) in custom_headers {
1335+
if let Some(extra) = redirect_cookies
1336+
&& !merged_cookies
1337+
&& name.eq_ignore_ascii_case("cookie")
1338+
{
1339+
merged_cookies = true;
1340+
builder = builder.header(name.as_str(), format!("{}; {}", value, extra));
1341+
continue;
1342+
}
13171343
builder = builder.header(name.as_str(), value.as_str());
13181344
}
13191345
}
1346+
if let Some(extra) = redirect_cookies
1347+
&& !merged_cookies
1348+
{
1349+
builder = builder.header("Cookie", extra);
1350+
}
13201351

13211352
let body_bytes = config.body.clone().unwrap_or_default();
13221353
builder
@@ -1666,6 +1697,21 @@ impl HyperClient {
16661697
let mut redirect_chain: Vec<RedirectHop> = Vec::new();
16671698
let mut hops = 0u32;
16681699

1700+
// Cookies picked up as we walk this chain. Created here and dropped
1701+
// when the request returns, so two concurrent requests can never see
1702+
// each other's cookies and a request's result depends only on its own
1703+
// inputs. `hop_cookies` is the `Cookie` header for the hop we're about
1704+
// to make, recomputed per hop because each one may be a different host.
1705+
// The caller's own cookies are recorded up front so the chain can
1706+
// never touch them: whatever they put in a `Cookie` header is what
1707+
// every hop sends.
1708+
let mut chain_cookies = config.should_forward_redirect_cookies().then(|| {
1709+
crate::cookies::ChainCookies::with_caller_cookies(crate::cookies::caller_cookie_names(
1710+
config.headers.as_deref().unwrap_or(&[]),
1711+
))
1712+
});
1713+
let mut hop_cookies: Option<String> = None;
1714+
16691715
loop {
16701716
// Decide the connection mode for the *current* target host on every
16711717
// hop, not just the first. A redirect can send the request to a
@@ -1695,9 +1741,16 @@ impl HyperClient {
16951741
};
16961742

16971743
let resp = if let Some(ref proxy_url) = proxy_url_for_fwd {
1698-
dispatch_forward_proxy(proxy_url, &uri, config, log).await?
1744+
dispatch_forward_proxy(proxy_url, &uri, config, log, hop_cookies.as_deref()).await?
16991745
} else {
1700-
dispatch_request(&cached.as_ref().unwrap().inner, &uri, config, log).await?
1746+
dispatch_request(
1747+
&cached.as_ref().unwrap().inner,
1748+
&uri,
1749+
config,
1750+
log,
1751+
hop_cookies.as_deref(),
1752+
)
1753+
.await?
17011754
};
17021755
let hop_ms = start.elapsed().as_millis();
17031756
debug_record(log, v, 1, &format!("<- {} ({}ms)", resp.status, hop_ms));
@@ -1741,6 +1794,42 @@ impl HyperClient {
17411794
peer_ip: hop_peer_ip,
17421795
});
17431796

1797+
// Take this hop's `Set-Cookie` headers, then work out which of
1798+
// everything collected so far applies to where we're going.
1799+
// The domain / path / Secure rules are what stop a cookie from
1800+
// following a redirect onto a host it doesn't belong to.
1801+
if let Some(chain) = chain_cookies.as_mut() {
1802+
let rejected = chain.store(&resp.headers, &uri);
1803+
if !rejected.caller_owned.is_empty() {
1804+
debug_record(
1805+
log,
1806+
v,
1807+
1,
1808+
&format!(
1809+
" Kept the caller's own cookie(s) over a Set-Cookie for: {}",
1810+
rejected.caller_owned.join(", ")
1811+
),
1812+
);
1813+
}
1814+
// Say so rather than quietly holding fewer cookies than
1815+
// the chain set: a cap nobody can see reads as coverage.
1816+
if !rejected.over_limit.is_empty() {
1817+
debug_record(
1818+
log,
1819+
v,
1820+
1,
1821+
&format!(
1822+
" Cookie limit reached, dropped: {}",
1823+
rejected.over_limit.join(", ")
1824+
),
1825+
);
1826+
}
1827+
hop_cookies = chain.header_for(&next_uri);
1828+
if let Some(ref c) = hop_cookies {
1829+
debug_record(log, v, 1, &format!(" Sending cookies: {}", c));
1830+
}
1831+
}
1832+
17441833
uri = next_uri;
17451834
continue;
17461835
}
@@ -2140,7 +2229,7 @@ mod tests {
21402229
fn test_build_request_auto_host_from_uri() {
21412230
let uri: http::Uri = "http://example.com:8080/path".parse().unwrap();
21422231
let config = RequestConfig::new("http://example.com:8080/path".to_string());
2143-
let req = build_request(&uri, &config, true, true).unwrap();
2232+
let req = build_request(&uri, &config, true, true, None).unwrap();
21442233
assert_eq!(req.headers().get("host").unwrap(), "example.com:8080");
21452234
}
21462235

@@ -2152,7 +2241,7 @@ mod tests {
21522241
// HTTP/2 HPACK block, which some origin servers reject.
21532242
let uri: http::Uri = "http://example.com:8080/path".parse().unwrap();
21542243
let config = RequestConfig::new("http://example.com:8080/path".to_string());
2155-
let req = build_request(&uri, &config, false, false).unwrap();
2244+
let req = build_request(&uri, &config, false, false, None).unwrap();
21562245
assert!(req.headers().get("host").is_none());
21572246
}
21582247

@@ -2161,7 +2250,7 @@ mod tests {
21612250
let uri: http::Uri = "http://example.com:8080/path".parse().unwrap();
21622251
let mut config = RequestConfig::new("http://example.com:8080/path".to_string());
21632252
config.headers = Some(vec![("Host".to_string(), "custom.host".to_string())]);
2164-
let req = build_request(&uri, &config, true, true).unwrap();
2253+
let req = build_request(&uri, &config, true, true, None).unwrap();
21652254
// Should only have the custom Host, not auto-derived
21662255
let hosts: Vec<_> = req.headers().get_all("host").iter().collect();
21672256
assert_eq!(hosts.len(), 1);
@@ -2176,7 +2265,7 @@ mod tests {
21762265
let uri: http::Uri = "http://example.com:8080/path".parse().unwrap();
21772266
let mut config = RequestConfig::new("http://example.com:8080/path".to_string());
21782267
config.headers = Some(vec![("Host".to_string(), "custom.host".to_string())]);
2179-
let req = build_request(&uri, &config, false, false).unwrap();
2268+
let req = build_request(&uri, &config, false, false, None).unwrap();
21802269
let hosts: Vec<_> = req.headers().get_all("host").iter().collect();
21812270
assert_eq!(hosts.len(), 1);
21822271
assert_eq!(hosts[0], "custom.host");
@@ -2190,7 +2279,7 @@ mod tests {
21902279
("Host".to_string(), "first.host".to_string()),
21912280
("Host".to_string(), "second.host".to_string()),
21922281
]);
2193-
let req = build_request(&uri, &config, true, true).unwrap();
2282+
let req = build_request(&uri, &config, true, true, None).unwrap();
21942283
let hosts: Vec<_> = req.headers().get_all("host").iter().collect();
21952284
assert_eq!(hosts.len(), 2);
21962285
assert_eq!(hosts[0], "first.host");
@@ -2201,15 +2290,15 @@ mod tests {
22012290
fn test_build_request_origin_form_strips_authority() {
22022291
let uri: http::Uri = "http://example.com:8080/path?q=1".parse().unwrap();
22032292
let config = RequestConfig::new("http://example.com:8080/path?q=1".to_string());
2204-
let req = build_request(&uri, &config, true, true).unwrap();
2293+
let req = build_request(&uri, &config, true, true, None).unwrap();
22052294
assert_eq!(req.uri(), "/path?q=1");
22062295
}
22072296

22082297
#[test]
22092298
fn test_build_request_absolute_form_preserves_uri() {
22102299
let uri: http::Uri = "http://example.com:8080/path?q=1".parse().unwrap();
22112300
let config = RequestConfig::new("http://example.com:8080/path?q=1".to_string());
2212-
let req = build_request(&uri, &config, false, false).unwrap();
2301+
let req = build_request(&uri, &config, false, false, None).unwrap();
22132302
assert_eq!(req.uri().to_string(), "http://example.com:8080/path?q=1");
22142303
}
22152304

@@ -2219,7 +2308,7 @@ mod tests {
22192308
// Simulate: origin_form=false (as dispatch_direct does when request_target is Some)
22202309
let uri: http::Uri = "http://evil.com/admin".parse().unwrap();
22212310
let config = RequestConfig::new("http://example.com/".to_string());
2222-
let req = build_request(&uri, &config, false, true).unwrap();
2311+
let req = build_request(&uri, &config, false, true, None).unwrap();
22232312
assert_eq!(req.uri().to_string(), "http://evil.com/admin");
22242313
}
22252314

0 commit comments

Comments
 (0)