Repository navigation
fix(netty): keep caller Cookie headers as written on redirects and auth retries - #2360
Merged
hyperxpro merged 7 commits intoNov 5, 2026
Merged
Conversation
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>
18 of 22 tasks
hyperxpro
requested changes
Oct 21, 2026
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>
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. |
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! |
14 tasks done
hyperxpro
approved these changes
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cookieheader as text, removing only the pairs that the response replaced, instead of decoding it leniently and re-encoding it strictly.;, asNettyRequestFactory.mergeCookiesdoes, so that a quote in front of a replaced pair cannot hide it and resend a stale session. Both now share one split.Set-Cookiedeletion 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.HttpUtils. Follow-up to fd97636 (GHSA-2jwh-9rmr-j4xf).Problem
Since fd97636, a caller-set
Cookieheader 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,
CallerCookiesdecoded the caller's header leniently and re-encoded the remaining pairs strictly. That broke headers that the first request had sent without complaint:Review also found that
CallerCookiescounted everySet-CookiewithMax-Age<=0as replacing the caller's cookie of that name, whatever its Domain and Path.Set-Cookie: SID=; Path=/elsewhere; Max-Age=0on a same-origin 302, 401 or 407 response cut the caller'sSID=mine, whether it was set as a cookie object or in aCookieheader. If the store held aSIDfor the next request, that older value was sent in its place.mainbehaves the same way.Change
;, asmergeCookiesdoes 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 asX=a"b; SID=oldorX="a; SID=oldwould become a single pair namedX. ASIDthat a redirect, 401 or 407 response rotated, or deleted withMax-Age=0, would stay in the header.mergeCookieswould 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.HttpUtils.cookiePairsandHttpUtils.cookiePairNamenow split and name the pairs formergeCookiesandCallerCookies.CallerCookiesstill folds names as the store does, whilemergeCookiescompares them as written; the Javadoc says so.prefs={"a":"x;y"}orA=1;B=2is not rewritten."; ". A header's pair names are checked before anything is built.Cookieheader pairs follow the same rule.Set-Cookie: S=; Path=/app; Max-Age=0on 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.Set-Cookiethat 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.CallerCookies.of. InRedirect30xInterceptor, remove a block that the new code made dead, and update a comment that described only the case without a cookie store.Behavior changes
Cookieheader 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.P=scoped; Path=/p, then GET/p/awithCookie: P=scopedand a 302 to/q/b:mainsends noP, because the pair counted as the store's and was dropped outside its Path. This change sendsP=scoped, since the caller wrote it.Cookieheader.mainand as a browser would.main: the store's other reasons to refuse a cookie are not repeated.Set-Cookiethat is not a deletion, if the store already holds its value for the next request.Compatibility
No breaking API change.
HttpUtilsgainscookiePairsandcookiePairName.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-Bytrailers perAGENTS.md. The review fixes were made with Claude Code, and their commits carry itsCo-Authored-Bytrailer.Test plan
New tests in
RedirectCookieRotationTestandAuthRetryCookieTestcover:Cookieheaders, and a raw pair deleted withMax-Age=0;Set-Cookiefor another path with the value of a stored cookie;HttpUtilsTestcovers the shared split. The proxy 407 test no longer sets a cookie, so it does not pin that a proxy'sSet-Cookieis stored against the origin. It fails onmain, where the strict encoder rejects the value.Broken variants of the deletion rule, each run against
RedirectCookieRotationTestandAuthRetryCookieTest:main: 2 failures: the deletion for another path on a 401 retry, and the deletion for another Domain.P=scopedfollows the redirect.P=scopedfollows instead of the storedP=root.Domain=example.org.Set-Cookiecounted without checking its Path: 1 failure, theSet-Cookiefor/elsewherewith the value of the stored cookie.Runs:
./mvnw -B -ntp -Dgpg.skip=true clean verifyon JDK 11: 1,814 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used.-Djvm): 270 tests each, 0 failures.Cookieheaders 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 installon JDK 11 passes with 1,867 tests, 0 failures, 0 errors, 11 skipped; Revapi passed.Cookie: z=1; flash=stale; a=2; flagthrough a same-origin redirect that setsflash, with the cookie store enabled. The next hop receivesz=1; a=2; flag; flash=redirect-cookie. Against AHC without this change, the valuelessflagis dropped and the test fails.