Skip to content

[bugfix] Fix non-ASCII cookie values silently dropped / crashing on Jetty 12 - #6697

Merged
line-o merged 4 commits into
developfrom
dp-investigate-header-encoding-hack
Oct 12, 2026
Merged

line-o merged 4 commits into
developfrom
dp-investigate-header-encoding-hack

Conversation

@duncdrum

Copy link
Copy Markdown
Contributor

Summary

Independent of and unrelated to #6692/#6693 (the URL-rewrite pipeline stack) -- this investigates and fixes a separate, pre-existing bug found while reviewing a 2010-era // TODO: remove this hack after fixing HTTP 1.1 comment during that other work.

HttpResponseWrapper#encode (added 2010-10-14, commit 67612ad4be) applies the same ISO-8859-1 byte-packing scheme to both header values (response:set-header()) and cookie values (response:set-cookie()), to work around the Java Servlet API's byte-per-char header serialization for non-ASCII text.

Measured empirically against a real Jetty 12 instance (not just inferred from the spec):

  • Headers: the hack still works. A non-ASCII header value's exact UTF-8 bytes reach the wire unchanged.
  • Cookies: the hack does not work, and never really did on this stack. RFC 6265's cookie-octet grammar excludes any byte >= 0x80 -- stricter than headers' more permissive treatment -- and Jetty 12 enforces this strictly. Depending on request shape, a non-ASCII cookie value either:
    • vanishes from the response entirely, with no warning or error anywhere, or
    • fails the entire request with a 500: org.eclipse.jetty.http.ComplianceViolationException: ... RFC6265 Cookie values characters restricted to US-ASCII: 0xd0 (forbidden).

What Changed

Commits 1-2 (measure, then red): a unit test pinning the exact byte-packing transformation, a raw-socket wire test against real Jetty 12 confirming the header/cookie divergence above, and an end-to-end round-trip test (response:set-cookie() → request:get-cookie-value()) reproducing the 500.

Commit 3 (fix): exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java -- cookie values now go through a separate encodeCookieValue() that percent-encodes instead of byte-packing, keeping the wire value strictly within cookie-octet's ASCII-safe range regardless of input text. The header path (encode()) is untouched. org.exist.xquery.functions.request.GetCookieValue#decode (the read side) is updated symmetrically to percent-decode, with a fallback to the raw value if decoding fails, so a cookie eXist didn't set itself (browser, another app on the same domain) doesn't crash a lookup over a coincidental % that isn't a valid escape.

Both historic "TODO: remove this hack after fixing HTTP 1.1" comments are replaced with what direct measurement now shows -- the header-side hack isn't a fixable HTTP/1.1 defect (it's a still-current characteristic of the Java Servlet header API) and remains genuinely necessary today, not dead code.

Test Plan

  • HttpResponseWrapperEncodingTest -- unit-level, pins the header byte-packing transformation (unchanged) and the new cookie percent-encoding.
  • HttpResponseWrapperEncodingWireTest -- real HTTP round trip via raw socket against live Jetty 12; confirms header behavior and documents the cookie divergence.
  • NonAsciiCookieRoundTripTest -- red against unfixed code (500), green after the fix; full response:set-cookie() → request:get-cookie-value() round trip.
  • Full org.exist.http.*/org.exist.http.servlets.*/org.exist.xquery.functions.request.*/org.exist.xquery.functions.response.* sweep: 132 tests, all green -- this is also the first test coverage cookies have had in this codebase at all.

@duncdrum
duncdrum force-pushed the dp-investigate-header-encoding-hack branch from 391fde9 to 2676db0 Compare September 11, 2026 07:02
@duncdrum duncdrum added this to v7.0.0 Sep 11, 2026
@duncdrum duncdrum added this to the eXist-7.0.0 milestone Sep 11, 2026
@duncdrum duncdrum moved this to In progress in v7.0.0 Sep 11, 2026
@duncdrum duncdrum added the bug issue confirmed as bug label Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

📊 XQTS result comparison

Comparison of this run against develop.

Metric develop this run Change
➖ Passed 28,868 (90.74%) 28,868 (90.74%) 0 (0.00 pp)
➖ Failures 1,546 1,546 0
➖ Errors 134 134 0
➖ Skipped 1,267 1,267 0
🧪 Total tests 31,815 31,815 0

Relative to develop: 0 newly passing, 0 newly failing, 0 new errors, 0 newly skipped — counting only tests recorded in both runs whose outcome changed.

Runtime: 429.0s (-75.62s vs develop).

@duncdrum
duncdrum marked this pull request as ready for review September 15, 2026 20:57
@duncdrum
duncdrum requested a review from a team as a code owner September 15, 2026 20:57
@joewiz

joewiz commented Sep 16, 2026

Copy link
Copy Markdown
Member

[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

The header/cookie split is right, and measuring the wire rather than reading the spec is what makes it convincing — RFC 6265's cookie-octet genuinely does exclude bytes >= 0x80 where header values tolerate them. This is also the first test coverage cookies have ever had here, which is worth something on its own.

One substantive finding, and a small suggestion that I think resolves it.

Switching the read side changes how existing cookies decode

URLDecoder.decode applies to every cookie already sitting in a browser, not only to ones written by the new code. Measured:

cookie value already in a browser decodes as today decodes as after this PR
aGVsbG8+d29ybGQ= (base64) aGVsbG8+d29ybGQ= aGVsbG8 d29ybGQ=
abc+def abc+def abc def
tok%C3%A9n tok%C3%A9n tokén
50% 50% 50% (unchanged — invalid escape, caught by the fallback)

Base64 is the case I would worry about: its alphabet includes +, session tokens are routinely base64, and the corruption is silent. The IllegalArgumentException fallback only catches malformed escapes, so a well-formed %C3%A9 in a foreign cookie gets decoded whether or not it was ever encoded.

The class javadoc does acknowledge the + ambiguity, but scopes it to "a cookie eXist didn't set itself". Two cases sit outside that framing: cookies this instance set before the upgrade are equally affected, and the %XX case is not mentioned at all.

Suggestion: encode space as %20 and leave + alone on the way back

URLEncoder/URLDecoder implement application/x-www-form-urlencoded, where space is +. Percent-encoding proper (RFC 3986) uses %20 and gives + no special meaning. Moving to that shape removes the + hazard entirely:

pre-existing cookie this PR with %20 + literal +
aGVsbG8+d29ybGQ= aGVsbG8 d29ybGQ= aGVsbG8+d29ybGQ=
abc+def abc def abc+def

and every round trip this PR already tests still passes — Cyrillic, a b, a+b, 50%, base64. Mechanically it is small: URLEncoder.encode(v, UTF_8).replace("+", "%20") on write, and on read protect a literal + before decoding (v.replace("+", "%2B")) or use a plain RFC 3986 decoder.

That leaves only the %XX ambiguity, which is inherent to decoding a namespace you do not own, and much rarer in practice than +.

Worth knowing: an RFC 3986 decodeForURI — a decoder that treats + as a literal — is already proposed in #6451 for the xmldb: URI functions, for exactly the same reason. If both land it would be good for them to share one implementation rather than grow two.

Minor

encode() still calls value.getBytes() with no charset, so it takes the platform default. That is UTF-8 on Java 18+ so it is correct in practice today, but the new javadoc above it now states "a value's UTF-8 bytes" explicitly — worth making the code say so too while you are in there: value.getBytes(UTF_8).

Test suggestion

Whatever you decide on the above, a cookie value containing a literal + (a base64 payload is the realistic case) and one containing a %XX sequence would both be worth pinning — neither is covered now, and they are the shapes that change behavior for existing deployments.

@duncdrum

Copy link
Copy Markdown
Contributor Author

Good catch — pushed a fix in 48cd9c0. Cookie encoding now rewrites the URLEncoder-produced + (space) to %20 on write, and escapes a literal + to %2B before decoding on read, so a pre-existing + (e.g. in a base64 token) is never mistaken for an encoded space. Also made the header path's UTF-8 byte extraction explicit, and pointed to #6451 in the docs for sharing an implementation later. Added tests pinning the + and %XX cases on both the encode and decode sides.

@duncdrum duncdrum self-assigned this Sep 20, 2026
@duncdrum duncdrum moved this from In progress to In review in v7.0.0 Sep 22, 2026
@duncdrum duncdrum removed their assignment Sep 24, 2026
@duncdrum
duncdrum requested a review from dizzzz September 29, 2026 11:49
@duncdrum

Copy link
Copy Markdown
Contributor Author

@joewiz please have a look again

@joewiz joewiz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

Thanks, @duncdrum, 48cd9c0 covers everything: a space is written as %20, a literal + is protected as %2B before decoding, getBytes(UTF_8) is explicit, and the tests pin both the + and the %XX cases. I ran HttpResponseWrapperEncodingTest, GetCookieValueDecodeTest and NonAsciiCookieRoundTripTest on the branch: all 11 pass.

One wording nit, not blocking: the new Javadoc on encodeCookieValue says that rewriting the encoded space to %20 "keeps a literal + passing through unencoded". URLEncoder encodes a literal + as %2B, which is what addCookieLeavesLiteralPlusPercentEncoded asserts, so the sentence could say that a literal + is written as %2B and so can't be confused with a space.

@duncdrum
duncdrum force-pushed the dp-investigate-header-encoding-hack branch from 48cd9c0 to ece2b3d Compare October 7, 2026 18:50
@duncdrum
duncdrum requested a review from a team October 10, 2026 10:13
@Test
public void measureWireBytesForNonAsciiHeaderAndCookie() throws IOException {
final Map<String, java.util.List<byte[]>> rawHeaders = fetchRawHeaders("/exist/apps/test-header-encoding-wire/test.xql");
System.err.println("ALL headers seen: " + rawHeaders.keySet());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

System.err.println -> done on purpose?

@duncdrum duncdrum Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No, leftover debugging. Removed in 5a66724.

System.err.println("ALL headers seen: " + rawHeaders.keySet());
for (final Map.Entry<String, java.util.List<byte[]>> e : rawHeaders.entrySet()) {
for (final byte[] v : e.getValue()) {
System.err.println(" " + e.getKey() + ": " + bytesToHex(v)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

System.err.println -> for debugging?

@duncdrum duncdrum Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same, removed in 5a66724.

// generic header path's more permissive obs-text treatment -- comparing the two isolates
// whether an absent Set-Cookie is specific to the non-ASCII value or a broader issue with
// this test's cookie-setting path.
final java.util.List<byte[]> setCookieValues = rawHeaders.getOrDefault("set-cookie", java.util.List.of());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is here a test missing?

@duncdrum duncdrum Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, it was only printing. Measurement 3 now asserts that ascii-cookie=plainvalue is present, that test-cookie is present with the percent-encoded value (URLEncoder.encode of the Cyrillic string), and that every Set-Cookie byte is printable ASCII. Fixed in 5a66724; the 4 affected test classes pass (12 tests).

@dizzzz
dizzzz requested review from a team, line-o and reinhapa October 10, 2026 13:53
@joewiz

joewiz commented Oct 10, 2026

Copy link
Copy Markdown
Member

[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

Thanks @duncdrum, the rebase and JUnit 5 migration look clean, and the diff against what I approved is test-only. +1 to @dizzzz's threads: with the fix in, measurement 3 in HttpResponseWrapperEncodingWireTest could assert that test-cookie is present and percent-encoded, rather than only printing it, and the System.err debug output could go. (The encodeCookieValue Javadoc nit from my last review is still open too: a literal + is written as %2B, not passed through.)

@duncdrum

Copy link
Copy Markdown
Contributor Author

Thx @dizzzz and @joewiz my laptop is currently blocked by me hunting a storage bug. I ll address the review once it is done.

@duncdrum
duncdrum force-pushed the dp-investigate-header-encoding-hack branch from ece2b3d to e814071 Compare October 11, 2026 11:15
@duncdrum

duncdrum commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks both. Addressed and force-pushed, rebased onto current develop (history folded into the commits that last touched each file):

  • HttpResponseWrapperEncodingWireTest: removed the System.err debug output, and measurement 3 now asserts the cookie is present and percent-encoded (5a66724).
  • encodeCookieValue Javadoc: now says a literal + is written as %2B and so can't be confused with a space (8710960).
  • Also cleaned up the four Codacy UnnecessaryFullyQualifiedName notices in the wire test.

Re-ran HttpResponseWrapperEncodingTest, HttpResponseWrapperEncodingWireTest, GetCookieValueDecodeTest and NonAsciiCookieRoundTripTest on the rebased branch: all green.

Confirms, rather than only infers from reading the source, what the
2010 (commit 67612ad) ISO-8859-1 byte-repacking hack in encode()
actually does today, header case and cookie case separately:

- HttpResponseWrapperEncodingTest: unit-level, pins the exact
  transformation (each UTF-8 byte of the value packed into one char)
  against a captured argument, not just described in a comment.

- HttpResponseWrapperEncodingWireTest: real HTTP round trip against a
  live Jetty 12 instance, reading raw response bytes over a plain
  Socket (not through a higher-level HTTP client, which would
  reintroduce the exact header-value charset ambiguity being measured).
  Empirical findings, with the hack active (as shipped):
    - A non-ASCII header value (response:set-header()) arrives on the
      wire as the value's exact UTF-8 byte sequence -- the hack still
      achieves its stated goal for headers on Jetty 12 today.
    - A non-ASCII cookie value (response:set-cookie()) does not appear
      on the wire AT ALL -- not mangled, entirely absent, with no
      warning or error logged even at WARN level -- while a same-call
      plain-ASCII cookie in the same response survives normally. Jetty
      12's cookie serialization silently drops it regardless of the
      hack; confirmed by also running with encode() temporarily
      short-circuited to identity (reverted after measuring), where a
      non-ASCII header value doesn't survive as garbled text either --
      it arrives completely empty, only the header name remains.

Both temporary modifications used to gather the "without the hack"
counterfactual were reverted immediately after measuring; no production
behavior change in this commit.
duncdrum and others added 3 commits October 11, 2026 13:19
…alue() round trip failure

Red against current HttpResponseWrapper/GetCookieValue. Worse than the
silent drop HttpResponseWrapperEncodingWireTest measured directly:
through this request shape, the whole request fails with a 500 --
org.eclipse.jetty.http.ComplianceViolationException: ... RFC6265 Cookie
values characters restricted to US-ASCII: 0xd0 (forbidden), the first
byte of "К" in UTF-8 -- rather than merely omitting the cookie.

Same root cause either way: RFC 6265's cookie-octet grammar excludes
bytes >= 0x80, and the shared ISO-8859-1 byte-packing scheme (which
works fine for the header path, per HttpResponseWrapperEncodingWireTest)
produces exactly those bytes for non-ASCII cookie values.
… byte-packing

Fixes the failure NonAsciiCookieRoundTripTest reproduces: a cookie value
set via response:set-cookie() containing non-ASCII text either vanishes
from the response entirely with no warning, or fails the whole request
with a 500 (org.eclipse.jetty.http.ComplianceViolationException: ...
RFC6265 Cookie values characters restricted to US-ASCII), depending on
request shape -- both measured empirically against a real Jetty 12
instance, not inferred from the spec.

Root cause: HttpResponseWrapper#encode (2010, commit 67612ad) applies
the same ISO-8859-1 byte-packing scheme to both header and cookie
values. That scheme is confirmed still correct and necessary for headers
on Jetty 12 (HttpResponseWrapperEncodingWireTest measures the raw wire
bytes). For cookies it produces bytes RFC 6265's cookie-octet grammar
excludes (anything >= 0x80) -- stricter than headers' more permissive
treatment -- which Jetty 12 enforces strictly enough to drop the cookie
or fail the request outright.

Fix: cookie values now go through a separate encodeCookieValue(), which
percent-encodes instead of byte-packing, keeping the wire value strictly
within cookie-octet's ASCII-safe range regardless of the input text.
GetCookieValue#decode (the read side, request:get-cookie-value()) is
updated symmetrically to percent-decode, falling back to the raw value
on a decode failure so a cookie this eXist instance didn't set itself
(browser, another app on the same domain) doesn't crash a lookup over a
coincidental '%' that isn't a valid escape.

Both historic "TODO: remove this hack after fixing HTTP 1.1" comments
replaced with what direct measurement now shows: the header-side hack
was never really about a fixable HTTP/1.1 defect (it's a still-current
characteristic of the Java Servlet header API), and is confirmed live
and necessary today -- not dead code to remove.

HttpResponseWrapperEncodingTest updated: the cookie case now asserts
percent-encoding (plain US-ASCII, round-trips via URLDecoder) instead of
the old byte-packing assertion, which no longer applies to cookies.
Header assertions unchanged. Full org.exist.http.*/org.exist.http.servlets.*/
org.exist.xquery.functions.request.*/org.exist.xquery.functions.response.*
sweep: 132 tests, all green -- this is also the first test coverage
cookies have had at all.
URLEncoder/URLDecoder implement application/x-www-form-urlencoded, not
RFC 3986 percent-encoding: they encode a space as '+' rather than
'%20'. That collides with a literal '+' already present in a cookie
value -- common in base64 payloads such as session tokens -- which
would then be indistinguishably decoded back to a space. This affects
not just foreign cookies but ones this instance itself set before this
fix, since GetCookieValue#decode runs on every incoming cookie value
regardless of who wrote it.

Rewrite the encoded space to '%20' on write, and escape a literal '+'
to '%2B' before decoding on read, removing the collision entirely
('+' is a valid cookie-octet byte per RFC 6265, so it never needed
escaping). Also makes the header-encoding path's UTF-8 byte extraction
explicit rather than relying on the platform default charset.

Same RFC 3986-vs-form-urlencoded issue is being addressed for the
xmldb: URI functions in #6451; pointed to from the inline docs as a
candidate to share one implementation with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@duncdrum
duncdrum force-pushed the dp-investigate-header-encoding-hack branch from e814071 to 8710960 Compare October 11, 2026 11:21
@line-o
line-o merged commit 3f110cb into develop Oct 12, 2026
16 checks passed
@line-o
line-o deleted the dp-investigate-header-encoding-hack branch October 12, 2026 18:24
@github-project-automation github-project-automation Bot moved this from In review to Done in v7.0.0 Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug issue confirmed as bug

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants