Repository navigation
[bugfix] Fix non-ASCII cookie values silently dropped / crashing on Jetty 12 - #6697
Conversation
391fde9 to
2676db0
Compare
📊 XQTS result comparisonComparison of this run against
Relative to Runtime: 429.0s (-75.62s vs |
|
[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 One substantive finding, and a small suggestion that I think resolves it. Switching the read side changes how existing cookies decode
Base64 is the case I would worry about: its alphabet includes The class javadoc does acknowledge the Suggestion: encode space as
|
| 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.
|
Good catch — pushed a fix in 48cd9c0. Cookie encoding now rewrites the URLEncoder-produced |
|
@joewiz please have a look again |
joewiz
left a comment
There was a problem hiding this comment.
[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.
48cd9c0 to
ece2b3d
Compare
| @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()); |
There was a problem hiding this comment.
System.err.println -> done on purpose?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
System.err.println -> for debugging?
| // 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()); |
There was a problem hiding this comment.
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).
|
[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 |
ece2b3d to
e814071
Compare
|
Thanks both. Addressed and force-pushed, rebased onto current develop (history folded into the commits that last touched each file):
Re-ran |
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.
…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>
e814071 to
8710960
Compare
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.1comment during that other work.HttpResponseWrapper#encode(added 2010-10-14, commit67612ad4be) 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):
cookie-octetgrammar 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: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 separateencodeCookieValue()that percent-encodes instead of byte-packing, keeping the wire value strictly withincookie-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; fullresponse:set-cookie()→request:get-cookie-value()round trip.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.