Skip to content

fix(netty): keep caller Cookie headers as written on redirects and auth retries - #2360

Merged
hyperxpro merged 7 commits into
AsyncHttpClient:mainfrom
mkurz:fix/raw-cookie-hop-reconciliation
Nov 5, 2026
Merged

hyperxpro merged 7 commits into
AsyncHttpClient:mainfrom
mkurz:fix/raw-cookie-hop-reconciliation

Conversation

@mkurz

@mkurz mkurz commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • On a redirect or an authentication retry, keep a caller-set Cookie header as text, removing only the pairs that the response replaced, instead of decoding it leniently and re-encoding it strictly.
  • Find those pairs by splitting at every ;, as NettyRequestFactory.mergeCookies does, so that a quote in front of a replaced pair cannot hide it and resend a stale session. Both now share one split.
  • Count a Set-Cookie deletion only where its Domain and Path apply, so that a deletion for another path no longer cuts the caller's cookie and lets an older stored one take its place.
  • No breaking API change: two helpers are added to HttpUtils. Follow-up to fd97636 (GHSA-2jwh-9rmr-j4xf).

Problem

Since fd97636, a caller-set Cookie header is kept when the cookie store also contributes cookies to a hop, with the pairs that the response replaced removed. The cookie store is enabled by default (ThreadSafeCookieStore), so this path covers ordinary clients. It applies to redirects and to authentication retries: 401 (Basic, Digest, NTLM) and proxy 407.

To find the replaced pairs, CallerCookies decoded the caller's header leniently and re-encoded the remaining pairs strictly. That broke headers that the first request had sent without complaint:

  • A value that the strict encoder rejects failed the redirect or retry, although the initial request had been sent.
  • Pairs could be reordered, and valueless or unbalanced-quote pairs were dropped.

Review also found that CallerCookies counted every Set-Cookie with Max-Age<=0 as replacing the caller's cookie of that name, whatever its Domain and Path. Set-Cookie: SID=; Path=/elsewhere; Max-Age=0 on a same-origin 302, 401 or 407 response cut the caller's SID=mine, whether it was set as a cookie object or in a Cookie header. If the store held a SID for the next request, that older value was sent in its place. main behaves the same way.

Change

  • Keep the caller-set header as text: remove only the pairs whose names the response replaced. Cookie objects are still encoded normally, and caller cookies are still stripped at cross-origin redirect boundaries.
  • Split at every ;, as mergeCookies does when it decides which names the caller set. RFC 6265 does not allow a ; in a cookie value, quoted or not. Keeping the text while splitting like the lenient decoder, which treats a double quote as opening a quoted string, would be unsafe. A header such as X=a"b; SID=old or X="a; SID=old would become a single pair named X. A SID that a redirect, 401 or 407 response rotated, or deleted with Max-Age=0, would stay in the header. mergeCookies would still count it as the caller's and drop the store's new value, so the next request would carry the stale session. Tests pin these cases.
  • One split for both: HttpUtils.cookiePairs and HttpUtils.cookiePairName now split and name the pairs for mergeCookies and CallerCookies. CallerCookies still folds names as the store does, while mergeCookies compares them as written; the Javadoc says so.
  • Cut out only the replaced pairs and keep the rest as written, separators included, so prefs={"a":"x;y"} or A=1;B=2 is not rewritten.
  • Return a header that loses no pair unchanged, instead of re-joining it with "; ". A header's pair names are checked before anything is built.
  • Count a deletion only where it applies: its Domain and Path must match the next request or the request it answered, as the store matches them (RFC 6265 sections 5.1.3 and 5.1.4). Cookie objects and Cookie header pairs follow the same rule.
    • Matching only the next request is not enough. Take Set-Cookie: S=; Path=/app; Max-Age=0 on a logout that redirects to /login. The deleted cookie was sent with the logout request, either added by the store or as a caller's pair that may be a copy of it. Once it is gone from the store, it can no longer be told from a caller's own cookie, and it would follow the redirect.
    • The store's other checks are not repeated, such as a public suffix as Domain or a Secure cookie over plaintext.
  • A Set-Cookie that is not a deletion counts only if it applies to the next request too, not just because a stored cookie for that request has the same value.
  • One store lookup per hop for the next request's cookies. A retry, which goes to the same URI again, reuses it for the current request.
  • Restore the Javadoc of CallerCookies.of. In Redirect30xInterceptor, remove a block that the new code made dead, and update a comment that described only the case without a cookie store.

Behavior changes

  • A caller-set Cookie header reaches the next hop of a redirect or authentication retry as written, minus the pairs the response replaced. Before, it could be reordered, lose pairs, or fail the hop.
  • A raw pair equal to a stored cookie now follows a same-origin redirect as written. Seed P=scoped; Path=/p, then GET /p/a with Cookie: P=scoped and a 302 to /q/b: main sends no P, because the pair counted as the store's and was dropped outside its Path. This change sends P=scoped, since the caller wrote it.
  • A deletion whose Domain or Path matches neither the request it answered nor the next one no longer removes the caller's cookie, as an object or in a Cookie header.
  • Unchanged: a deletion that does apply still removes the caller's cookie of that name. If the store keeps a cookie of that name for the next request, for another path, that one is sent instead, as on main and as a browser would.
  • Not covered, as on main: the store's other reasons to refuse a cookie are not repeated.
    • A deletion that matches by Domain and Path but that the store refuses still removes the caller's cookie. Examples are a public suffix as Domain, or a Secure cookie over plaintext.
    • So does a refused Set-Cookie that is not a deletion, if the store already holds its value for the next request.
    • Covering these would need the store's own decision on each cookie.

Compatibility

No breaking API change. HttpUtils gains cookiePairs and cookiePairName.

AI disclosure

OpenAI Codex and Claude Code on behalf of Matthias Kurz. The first commit was written in two steps, the first with Codex and the second with Claude Code, and squashed; it carries both Co-Authored-By trailers per AGENTS.md. The review fixes were made with Claude Code, and their commits carry its Co-Authored-By trailer.

Test plan

New tests in RedirectCookieRotationTest and AuthRetryCookieTest cover:

  • redirects with cookie rotation, and Digest, proxy 407 and NTLM (both rounds) retries;
  • the lax encoder, several caller Cookie headers, and a raw pair deleted with Max-Age=0;
  • quotes in front of a replaced pair, and byte-exact kept text;
  • deletions:
    • for another path, or for another Domain, on a redirect and on a 401 retry, for cookie objects and header pairs, with and without a stored cookie of that name;
    • a logout that deletes a path-scoped cookie and redirects elsewhere, for cookie objects and header pairs, also beside a second stored cookie of that name;
  • a non-deleting Set-Cookie for another path with the value of a stored cookie;
  • a raw pair equal to a stored, path-scoped cookie, which follows a redirect out of its Path.

HttpUtilsTest covers the shared split. The proxy 407 test no longer sets a cookie, so it does not pin that a proxy's Set-Cookie is stored against the origin. It fails on main, where the strict encoder rejects the value.

Broken variants of the deletion rule, each run against RedirectCookieRotationTest and AuthRetryCookieTest:

  • Every deletion counted, as on main: 2 failures: the deletion for another path on a 401 retry, and the deletion for another Domain.
  • A deletion counted only if it applies to the next request: 2 failures, both logout tests: the deleted P=scoped follows the redirect.
  • The previous revision's rule, which kept a header pair while the store held a cookie of that name for the next request: 1 failure, the logout beside a second stored cookie: the caller's P=scoped follows instead of the stored P=root.
  • The Domain not checked: 1 failure, the deletion with Domain=example.org.
  • A non-deleting Set-Cookie counted without checking its Path: 1 failure, the Set-Cookie for /elsewhere with the value of the stored cookie.

Runs:

  • Each new commit compiles with its tests.
  • ./mvnw -B -ntp -Dgpg.skip=true clean verify on JDK 11: 1,814 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used.
  • The cookie and redirect tests, compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's -Djvm): 270 tests each, 0 failures.
  • All four open AHC changes merged together (raw Cookie headers on retries, ResponseBodyControl.execute, suspend/resume order, demand-bounded decompression): they merge without conflicts, together and in every pair, and ./mvnw -B -ntp -Dgpg.skip=true clean install on JDK 11 passes with 1,867 tests, 0 failures, 0 errors, 11 skipped; Revapi passed.
  • Play WS against that merged build, Scala 2.13 and 3.3, JDK 17: all tests pass. A Play WS test sends Cookie: z=1; flash=stale; a=2; flag through a same-origin redirect that sets flash, with the cookie store enabled. The next hop receives z=1; a=2; flag; flash=redirect-cookie. Against AHC without this change, the valueless flag is dropped and the test fails.

A redirect or authentication retry decoded a caller-set Cookie header
leniently, then re-encoded it strictly, whenever the cookie store also
contributed cookies to the hop. Values accepted on the first hop could
therefore fail on the next one, pairs could be reordered, and valueless
or unbalanced-quote pairs were dropped.

Keep caller-set header pairs as text and cut out only the pairs whose
names the response replaced. The rest stays as written, separators
included, so prefs={"a":"x;y"} or A=1;B=2 is not rewritten, and a
header that loses no pair is returned unchanged. Cookie objects are
still encoded normally, and caller cookies are still stripped at
cross-origin redirect boundaries.

Find the pairs by splitting on every ';', as
NettyRequestFactory.mergeCookies does when it decides which names the
caller set; RFC 6265 does not allow a ';' in a cookie value, quoted or
not. Treating a double quote as opening a quoted string would let a
header such as X=a"b; SID=old hide a SID that a redirect, 401 or 407
response rotated or deleted, so that the next request resent the stale
session.

Restore the Javadoc of CallerCookies.of, and fix the comment on the
Cookie header in Redirect30xInterceptor, which still said the caller's
pairs travel in the cookie list. Add tests for redirects with cookie
rotation, Digest, proxy and NTLM retries, the lax encoder, several
caller Cookie headers, a raw pair deleted with Max-Age=0, quotes in
front of a replaced pair, and byte-exact kept text.

Follow-up to fd97636 (GHSA-2jwh-9rmr-j4xf).

OpenAI Codex and Claude Code on behalf of Matthias Kurz

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mkurz and others added 6 commits October 22, 2026 14:12
A redirect or authentication retry drops the request's cookies that the
response replaced, so that the store's current values are sent instead.
Every Set-Cookie with Max-Age<=0 counted, whatever its Domain and Path.
A deletion for another path then cut the caller's cookie, and the
store's cookie of that name, if it held one, was sent in its place.

Count a deletion only if its Domain and Path match the next request or
the one it answered, as the store matches them. The latter keeps the
cookie that the response deleted from following a redirect to another
path: once gone from the store, the store's cookie can no longer be
told from the caller's, and a caller's pair may be a copy of it. Cookie
objects and Cookie header pairs follow the same rule.

A Set-Cookie that is not a deletion counts only if it applies to the
next request too, not just because a stored cookie for that request
has the same value.

The store's other checks, such as a public suffix as Domain or a Secure
cookie over plaintext, are not repeated. A deletion that the store
refuses for such a reason still counts, as before.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pair of a caller's Cookie header now follows a same-origin redirect
as written, even when the store holds a cookie with the same name and
value. Before, such a pair counted as the store's, and was dropped
once the redirect left that cookie's Path. Pin the new behavior: a
caller who wrote the pair meant to send it.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NettyRequestFactory and CallerCookies each split a caller's Cookie
header into pairs and named them, in two loops that had to agree. Move
the split and the naming to HttpUtils and use them in both.
CallerCookies still folds names as the store does, while the merge
compares them as written; the Javadoc now says so instead of claiming
that both work the same way.

CallerCookies also checks a header's pair names before it builds
anything, so a header without a replaced pair is no longer copied.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CallerCookies.refresh asked the store for the next request's cookies
up to twice and for the current request's once, three lookups of the
same URI on an authentication retry. Look up the next request's
cookies once and pass them down, and reuse them for the current
request on a retry. A redirect still needs both.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
propagatedHeaders removed the Cookie header whenever there was a cookie
store. On a hop that strips credentials it is already gone, and on any
other CallerCookies.refresh sets it again right after. Drop the block
and its parameter, and say in the comment above that, with a store,
CallerCookies tells the caller's cookies from the store's.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two tests split the received Cookie header without a null check, so a
retry without any cookie failed with a NullPointerException instead of
an assertion. Use a null-safe helper for both.

The proxy authentication test pinned that a Set-Cookie on the proxy's
own 407 is stored against the origin and replaces the caller's cookie,
existing behavior that this change should not cement. Its 407 now sets
no cookie, so the test checks only that the caller's Cookie header
survives the retry as written. It fails on main, where the strict
encoder rejects the value "a b".

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mkurz

mkurz commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I've pushed six commits on top and updated the description. The deletion check differs a little from what you suggested; the first reply explains why. Replies are inline.

@mkurz
mkurz requested a review from hyperxpro October 22, 2026 14:02
@mkurz

mkurz commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

@hyperxpro Would be great if you find time to review all my pull requests https://github.com/AsyncHttpClient/async-http-client/pulls?q=is%3Apr+state%3Aopen+author%3Amkurz

Thanks you very much for your time!

@hyperxpro
hyperxpro merged commit 1a45b8c into AsyncHttpClient:main Nov 5, 2026
13 checks passed
@mkurz
mkurz deleted the fix/raw-cookie-hop-reconciliation branch November 5, 2026 20:26
@mkurz mkurz mentioned this pull request Nov 5, 2026
5 tasks done
hyperxpro pushed a commit that referenced this pull request Nov 6, 2026
## Summary

- Upgrades Netty from 4.2.19.Final to 4.2.20.Final. Its [security
fixes](https://github.com/netty/netty/releases/tag/netty-4.2.20.Final)
include several in modules AHC uses: three out-of-bounds reads in
`netty-buffer`, unbounded memory usage in `netty-codec-http2`,
insufficient control character neutralization in `netty-codec-http`,
insufficient entropy in `netty-resolver-dns`, and an mTLS bypass via
session resumption in `netty-handler`.
- On 4.2.20, two of AHC's tests fail, both because of Netty changes.
This PR adapts AHC to them.

## Change

- **Upgrade Netty to 4.2.20.Final, and check the second gzip magic
byte:** `JdkZlibDecoder` now rejects a gzip header with a wrong second
magic byte (netty/netty#17972). The gzip inflate loop from #2357 follows
`JdkZlibDecoder`, and `Http1ContentDecompressorTest` compares the two,
so it now checks that byte too. Both bytes are read unsigned, since
`0x8b` is negative as a signed byte.
- **Keep zstd working at a non-positive threshold:** since
netty/netty#17896, the `ZstdDecoder` that `HttpContentDecompressor`
creates sizes its output buffers by
`io.netty.compression.defaultMaxForwardBytes`. At 0 it hands on an empty
body without an error; below 0 it fails with `IllegalArgumentException:
initialCapacity : -1`. AHC leaves zstd to that decoder while the
property is not positive (#2362), so it now replaces it with a
`ZstdDecoder` whose buffers hold `MAX_PART_SIZE`. The size limit still
applies, and the body is still decompressed eagerly there, as before. At
the default value of the property nothing changes. netty/netty#18289
fixes the decoder in Netty; this workaround is harmless with that fix
and can go once AHC requires a Netty version that has it.

## Notes

- Netty 4.2.0 to 4.2.4 have no `ZstdDecoder(int)` constructor.
Overriding AHC's Netty with one of those and also setting the property
to 0 or below would fail zstd responses with a `NoSuchMethodError`.

## AI disclosure

Claude Code on behalf of Matthias Kurz. Both commits include
`Co-Authored-By` per `AGENTS.md`.

## Test plan

- [x] `main` at `5542c9fc4` with `-Dnetty.version=4.2.20.Final`: 1,857
tests, 2 failures:
- `Http1ContentDecompressorTest.decodesLikeNettyForEveryBodyAndSplit`,
case "gzip second magic byte ignored": Netty now fails that body, AHC
did not.
- `Http1ContentDecompressorForwardThresholdTest`: "the zstd body
differs".
- [x] `Http1ContentDecompressorForwardThresholdTest` now runs at
thresholds 0 and -1, each in a fresh JVM, and also checks the size limit
for zstd and that every exchange releases its input and buffers. On
4.2.20 without the zstd commit it fails at both ("the zstd body
differs", "the zstd body did not end"). With it, it passes on 4.2.19 and
4.2.20.
- [x] `./mvnw -B -ntp -Dgpg.skip=true clean verify` on JDK 11, on `main`
`3e95ec8c2`: 1,905 tests, 0 failures, 0 errors, 11 skipped; Revapi
passed. No test-skipping flags were used.
- [x] `Http1ContentDecompressorForwardThresholdTest` and
`Http1ContentDecompressorTest` compiled on JDK 11 and run on JDK 17, 21
and 25 (Maven Surefire's `-Djvm`): 36 tests each, 0 failures.
- [x] This branch merged with #2369: `clean verify` on JDK 11, 1,914
tests, 0 failures, 0 errors, 11 skipped; Revapi passed (run before
#2359, #2360 and #2361 were merged, with those three merged in as well).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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