Skip to content

feat(qwp): support browser negotiation and session authentication - #7531

Open
glasstiger wants to merge 56 commits into
masterfrom
ia_node_qwp
Open

feat(qwp): support browser negotiation and session authentication#7531
glasstiger wants to merge 56 commits into
masterfrom
ia_node_qwp

Conversation

@glasstiger

@glasstiger glasstiger commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • allow same-origin browser WebSocket upgrades for QWP ingress and egress
  • authenticate browser connections through the existing qdb_session cookie flow
  • negotiate durable ACKs, result compression, and ingress batch-row limits through QWP messages when browser WebSocket APIs cannot send custom upgrade headers
  • retain the existing header-based negotiation path for Node.js and other non-browser clients

This is the server-side companion to the JavaScript QWP client. The new negotiation fields are optional, so older clients continue to use the existing behavior.

How browser negotiation works

Browser JavaScript cannot set custom upgrade headers or read the upgrade response, so each capability gains a browser-safe carrier alongside the existing header. Headers are still preferred; the URL parameter is consulted only when the header is absent.

Capability Non-browser carrier Browser carrier
Durable ACK request (ingress) X-QWP-Request-Durable-Ack header Sec-WebSocket-Protocol: questdb.qwp.durable-ack.v1
Ingress batch cap (server → client) upgrade response header qwp_browser_handshake=v1STATUS_SERVER_INFO frame
Egress compression request X-QWP-Accept-Encoding header qwp_accept_encoding URL parameter
Egress compression result Content-Encoding response header CAP_COMPRESSION plus codec/level in the SERVER_INFO frame
Egress batch-row preference X-QWP-Max-Batch-Rows header qwp_max_batch_rows URL parameter

Out-of-range batch-row values are clamped to the server-authoritative cap rather than failing the handshake, so one buggy client cannot break the upgrade.

Origin handling is the CSWSH control: RFC 6455 browsers always send Origin and cannot let JavaScript remove it, while machine clients normally omit it. An upgrade carrying an Origin that is not same-origin with the request Host is rejected; an upgrade with no Origin is unaffected. Fragmented upgrade requests are rejected rather than reassembled.

Session authentication reuses the existing cookie handler: HttpCookieHandler gains getSessionCookieValue(), and both the ingress and egress upgrade responses emit Set-Cookie when a session is created or rotated, so a browser that logged in over HTTP carries qdb_session into the WebSocket.

Coverage

  • browser origin and session-authentication tests
  • fragmented upgrade rejection coverage
  • ingress capability and batch-limit negotiation tests
  • egress query-flag and compression negotiation tests
  • WebSocket handshake coverage

Related work

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: fc9b4daf-f00c-4abe-a58f-01713ba81f9c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@glasstiger

Copy link
Copy Markdown
Contributor Author

/azp run macwin

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

glasstiger and others added 21 commits September 7, 2026 15:31
CLAUDE.md groups class members by kind and sorts them alphabetically,
and the browser negotiation work inserted several members out of place.
QwpConstants now lists FLAG_DURABLE_ACK_POLL after FLAG_DELTA_SYMBOL_DICT
and STATUS_SERVER_INFO after STATUS_SECURITY_ERROR, so both blocks read
in order again. QwpIngressHttpProcessor moves
WEBSOCKET_PROTOCOL_QWP_DURABLE_ACK after VALUE_WEBSOCKET, slots
containsWebSocketProtocol and getSessionCookieValueBytes into place among
the public statics, and lifts startsWithIgnoreCaseAscii and toLowerAscii
out of the middle of that block into the trailing private-static block
beside buildVersionBytes and containsUpgrade. The three test classes the
same work added are sorted too.

misdirectedRequestWithRoleSize(byte[]) and
writeMisdirectedRequestWithRole(long, int, byte[]) were left behind when
the 421 path moved to the overloads that carry the session cookie. A
search across both repositories and the client submodule, over every file
type, finds no caller, so they go.

The SERVER_INFO_BODY_MAX_BYTES javadoc still described the bound as 26
fixed bytes after the constant grew to 28 for the browser compression
trailer; it now matches the value and says what the extra bytes cover.

No behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QffWppgMiw9YUKTaYnBinm
The IntelliJ formatter check on CI reformats the repo and diffs the
result, and QwpIngressHttpProcessor tripped it: two javadoc blocks had
lost their blank line separator and one had picked up three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QffWppgMiw9YUKTaYnBinm
QwpBrowserOriginTest exercised the authority byte loop only through its
three accepting cases. Every rejecting case returned earlier, on the
scheme branch or on the authority-length check, so nothing asserted
that isSameOrigin rejects an equal-length authority whose bytes differ
-- the direction that matters for the CSWSH gate.

Neutralising that comparison so it always reports a match left the
class green at 3/3, as did dropping the explicit '/' rejection and the
host == null guard.

Add three assertions covering those clauses: an equal-length authority
with different bytes, a forged Host that reproduces a path-bearing
Origin byte for byte, and an absent Host. Each one fails when the
clause it pins is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QffWppgMiw9YUKTaYnBinm
Three paths added by the browser negotiation work had no test that
could fail if they regressed.

The qwp_accept_encoding and qwp_max_batch_rows URL carriers had no
test reference at all, so swapping either getUrlParam key in
onHeadersReady left every suite green. QwpBrowserNegotiationWireTest
upgrades a real read socket with each carrier and asserts the wire
effect: CAP_COMPRESSION plus the codec/level trailer for the first,
three single-row RESULT_BATCH frames for the second, each against a
control connection that omits the carrier. Both key swaps now fail.
QwpWireTestFixtures.performReadHandshake gains a query-string
overload so its six existing callers keep their signature.

The durable-ack poll never produced a STATUS_DURABLE_ACK in any test:
the leapfrog suite runs against a disabled registry, so nothing
covered the frame's stated purpose. Arm the registry between the data
frame and the poll, leaving the poll as the only flush point able to
carry the new watermark.

Finally, isDurableAckPoll's magic, version and payload-length
conjuncts were never falsified; deleting any of the three failed no
test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QffWppgMiw9YUKTaYnBinm
The SERVER_INFO_BODY_MAX_BYTES javadoc enumerated 26 bytes of fixed
body fields, the 2-byte CAP_ZONE length prefix and the 2-byte browser
compression trailer, but the constant summed them to 28. Spell the
breakdown out in the expression itself so it cannot drift again, and
drop the "tight bound" claim along with it: EntQwpServerInfoProvider
populates a third u16-capped id (zone_id), so the reservation is a
floor the ids share rather than an upper bound on the body. Nothing
functional moves - writeServerInfo truncates inside the cap it is
handed, and the check already reserved ~128 KB of headroom.

QwpWireTestFixtures grows a readHttpHeaders helper and turns public.
That retires five hand-rolled CRLFCRLF readers: two in OSS tests, two
in Enterprise ones, and a fifth inlined in performReadHandshake. The
shared reader tolerates a stream that ends early and returns what
arrived, which is what three of the four copies did; the callers that
printed only the status line now print the whole response, so a
truncated upgrade still says something.

server.conf's comment for qwp.browser.tls.termination.enabled named
one prerequisite of the same-origin gate but not the other:
isSameOrigin compares Origin against the raw Host header, so a
terminator that rewrites Host - nginx's proxy_pass defaults to
$proxy_host - fails every browser upgrade with "Origin header not
allowed on QWP WebSocket".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYfQvZ6aCkMN9jnEw1uKfb
The qwp.browser.tls.termination.enabled documentation named
"proxy_set_header Host $host" as the safe nginx form. nginx resolves
$host to the hostname alone and drops the port, while a browser
serializes any non-default port into Origin. isSameOrigin compares the
two authorities byte for byte, so a TLS terminator listening on any
port other than 443 rejects every browser upgrade with "Origin header
not allowed on QWP WebSocket" -- on a deployment configured exactly as
the property documented.

The comment now prescribes $http_host, which forwards the header
verbatim, and states the requirement the check imposes: the terminator
must preserve Host including its port. It also names both nginx traps
as traps -- proxy_pass defaulting to $proxy_host, and the $host in the
widely copied WebSocket snippet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYfQvZ6aCkMN9jnEw1uKfb
The durable-ack-poll reject arm of handleBinaryMessage sent
STATUS_PARSE_ERROR without first flushing the connection's pending
cumulative ack, unlike the error arm at the tail of the same method.
Both frames go out either way, but a store-and-forward sender that
treats the error as terminal tears the connection down on reading it
and never sees the ack covering the frames already committed in that
pass, so its reconnect replays and duplicates them. The arm now
flushes first, with the same onErrorBlocked handling its sibling uses.

QwpEgressUpgradeProcessor read the browser's qwp_accept_encoding URL
parameter only when X-QWP-Accept-Encoding was absent, so an injected
header won outright and left browserCompressionNegotiation false: the
server compressed the wire while withholding CAP_COMPRESSION and the
codec/level trailer, and the browser decoded compressed frames as raw.
negotiateMaxBatchRows already guards against exactly that injection by
taking the stricter of its two carriers. A new negotiateAcceptEncoding
gives compression the same protection -- the URL parameter wins, since
only the client's own connect URL can carry it -- and the
CAP_COMPRESSION bit now keys off which carrier the client used rather
than which value won. A header-only native client and a URL-only
browser both resolve as before.

server.conf now records that qwp.browser.tls.termination.enabled
governs the origin check alone and does not mark qdb_session Secure.
That attribute follows QuestDB's own TLS setting, which is off by
definition in the very topology the flag exists for.

New tests close the gaps those fixes exposed:

- A non-rotating upgrade must emit no Set-Cookie, on both endpoints.
  Dropping the empty-session guard in getSessionCookieValueBytes
  otherwise answers every 101 and 421 with "qdb_session=" plus a
  30-day Max-Age, clearing the browser's live session.
- The browser SERVER_INFO frame stays absent when
  qwp_browser_handshake is missing, unknown or empty, and the positive
  case pins the advertised cap against X-QWP-Max-Batch-Size instead of
  merely asserting it positive.
- A real socket upgrade to /write/v4?qwp_browser_handshake=v1 proves
  the parameter survives route matching on the ingress path; the
  previous coverage drove it through a mock request header only.
  performReadHandshake and the new performWriteHandshake now share one
  body in the fixture.
- The 101 does not name the durable-ack subprotocol to a client that
  offered none, which RFC 6455 s4.1 makes such a client fail on.
- negotiateAcceptEncoding resolves all four carrier combinations.
- The poll reject puts its cumulative ack ahead of the error.

Every new assertion fails against a mutation of the line it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYfQvZ6aCkMN9jnEw1uKfb
The browser carrier mirrors X-QWP-Accept-Encoding, whose documented value
grammar is "zstd;level=N", but only the header is delivered verbatim. The
query string goes through HttpHeaderParser.urlDecode, which re-keys a
parameter on every unescaped '='. So ?qwp_accept_encoding=zstd;level=5
parses as the parameter "zstd;level" with value "5", the
qwp_accept_encoding key is absent entirely, and the request reads as "no
preference" -- the client asked for compression and silently got a raw
wire.

The parser behaviour predates this work, but this is the first QWP URL
parameter whose own value grammar contains '=', so the branch only became
reachable now. State the percent-encoding requirement on
negotiateAcceptEncoding and on the URL_PARAM_QWP_ACCEPT_ENCODING
declaration, and pin both halves on the wire: the encoded form must reach
the negotiator with its level intact, and the raw form must be dropped
whole rather than half-applied. A bare "zstd" clamps to level 1, so
asserting level 5 proves the parameter survived the round trip.

These two files also carry the member-order and dead-overload cleanups
described in the following commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
handleBinaryMessage marks a refused durable-ack poll's sequence
unresolved before flushing, so the cumulative OK ack cannot cover it or
anything pipelined behind it. Nothing exercised that: every existing poll
test either ends at the poll or sends nothing after it, so deleting the
marker left the whole suite green.

Drive data, poll, data through one pass with durable ack switched off and
assert the watermark stops at the frame before the refused poll and the
tail never commits. Without the marker the ack jumps to 2 and the tail
row lands: a store-and-forward sender that treats STATUS_PARSE_ERROR as
terminal tears down before reading that ack, replays from its old
watermark, and duplicates the tail frame's rows.

Also reattach the javadoc that described hasErrorResponseForSeq. A helper
added earlier landed between the comment and its method, leaving the
comment on hasDurableAckFrame, which takes no sequence and tests for
equality with STATUS_DURABLE_ACK rather than inequality with STATUS_OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
server.conf told the reader that the qdb_session Secure attribute
"follows QuestDB's own TLS setting". That describes Enterprise. Open
source never sets Secure at all -- HttpCookieHandlerImpl writes the
SESSION_COOKIE_ATTRIBUTES constant, which carries only HttpOnly, Path,
SameSite and Max-Age -- so an operator reading this file and enabling
QuestDB's own TLS would over-trust the cookie. Say what each edition
actually does; the paragraph's advice for the TLS-terminator topology is
unchanged.

Promoting effectiveMaxBatchSize to a field put an int under a comment
describing "header bytes ... Null when the cap collapses to zero". Give
each field its own comment.

Sort the members this branch added into the positions the project's
arrangement rules put them in: CAP_COMPRESSION before CAP_QUERY_FLAGS,
isDurableAckPoll before isMessageMagic, getSessionCookieValue ahead of
parseCookies (the interface already had it there),
RESPONSE_WEBSOCKET_PROTOCOL_DURABLE_ACK after RESPONSE_SUFFIX, the public
static writeBrowserServerInfoFrame out of the instance-method run, and in
the egress processor the private parseMaxBatchRows out of the public
block and the now-public writeServerInfoFrame out of the private one.

Also record on isDurableAckPoll why every term is an equality: relaxing
the length to >= or the flags to a bitmask test would let the server ack
a sequence whose payload it never processed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
QwpWireTestFixtures exists so the OSS and Enterprise test trees cannot
drift apart on the QWP byte layouts, but the browser-shaped upgrade
request was still stamped out by hand at every call site, and the
one-row ingress message and QWP header assertion had no shared home at
all.

Add browserUpgradeRequest, encodeSingleLongRow and assertQwpMessageKind
to the fixture, plus the RFC 6455 handshake nonce the browser tests
share, and route the fragmentation test's two upgrade builders through
them. encodeSingleLongRow uses the production QwpVarint encoder rather
than a fourth hand-rolled varint writer.

The Enterprise support class re-derives all three today; it picks them
up in the companion change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
Both guards the poll path relies on were green with the guard deleted.

testDurableAckPollMustNotCommitDeferredGroup asserted the cumulative
ack stays at -1, but setHighestProcessedSequence already refuses that
advance through its last-resort uncommitted-deferred-rows clamp, which
returns without assigning and differs only by a LOG.critical line. The
observable state was therefore identical with and without the
hasUncommittedDeferredRows guard in handleBinaryMessage. Count the
calls into setHighestProcessedSequence instead, so the test fails when
the poll arm asks for the advance at all -- the clamp is documented as
containment for a regression of exactly this path, so a test that
leans on it proves nothing.

testRecognizesExactDurableAckPollFrame mutated the length to
HEADER_SIZE - 1 and the flags to FLAG_DEFER_COMMIT, and isDurableAckPoll
rejects both under the relaxed forms its own contract warns about: a
shorter frame fails >= too, and 0x01 & 0x02 is zero. Add the two
mutations that separate them -- a longer frame and the
FLAG_DURABLE_ACK_POLL | FLAG_DEFER_COMMIT superset.

Verified by re-running both mutations: each now reddens its test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
Two gaps on the browser upgrade response path.

writeBrowserServerInfoFrame took no buffer size and wrote seven bytes
unconditionally, unlike writeMisdirectedRequestWithRole and the egress
writeServerInfoFrame, which both answer -1 when short. Its caller's
requiredHandshakeSize reservation was the only thing keeping it inside
the raw send buffer, and no test covered that term -- every
buffer-too-small case omits qwp_browser_handshake. Give it the same
size argument and -1 contract, fail the handshake when it reports no
room, and pin the boundary from both ends: a unit case either side of
the seven bytes, and an onHeadersReady case on a buffer measured to sit
one byte short of them.

The egress processor threads sessionCookieValueBytes through its own
responseSize and writeResponse pair, but both rotation tests upgraded
/write/v4, so only the null branch ever ran on /read/v1. Parameterise
the rotation test over both routes. Dropping the argument from one call
but not the other puts the SERVER_INFO frame at the wrong offset in the
send buffer, which the null branch cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
The ingress upgrade told a browser that durable acknowledgements were
unavailable by withholding the questdb.qwp.durable-ack.v1 subprotocol
echo from an otherwise successful 101. A browser cannot read that
signal. The WHATWG "establish a WebSocket connection" algorithm fails
the whole connection when the client offered a subprotocol and the
response names none, so the socket never opens and the page sees an
opaque error instead. The client's own QwpDurableAckUnavailableError,
which inspects socket.protocol after the open, could never fire.

QwpIngressUpgradeProcessor now echoes the token whenever the client
offered it. The echo confirms that the server speaks the browser
negotiation rather than that the capability is on, and RFC 6455 still
holds because the server never names a token the client did not offer.
The verdict moves to the browser SERVER_INFO frame as the new
SERVER_INFO_CAP_DURABLE_ACK bit, which a browser can read on an open
socket.

Either browser carrier now pulls that frame, so a client that wants
only durable ACK no longer has to pass qwp_browser_handshake=v1 to
learn whether it got it. The frame grows from five bytes to six, and
BROWSER_SERVER_INFO_PAYLOAD_BYTES and BROWSER_SERVER_INFO_WS_FRAME_BYTES
now name that size so onHeadersReady's send-buffer reservation and
writeBrowserServerInfoFrame cannot drift apart. The upgrade also logs a
capability gap so an operator can diagnose one without a client.

This changes a wire format the same branch introduces, so nothing
released depends on it, but it has to land together with
nodejs-questdb-client#62: until the client reads the capability bit, the
unconditional echo would make it report durable ACK as enabled against a
server that has it switched off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
The QWP upgrade refused a cross-origin browser with "Origin header not
allowed on QWP WebSocket". That was accurate while the guard rejected
every Origin, but the same-origin allowance made it a misdirection: an
Origin IS allowed now, as long as it matches Host. An operator reading
it goes looking for a way to turn browser support on, when the cause is
almost always a reverse proxy that rewrote Host or dropped its port. The
message now reads "Origin is not same-origin with Host on QWP
WebSocket", and ERROR_ORIGIN_HEADER_NOT_ALLOWED becomes
ERROR_CROSS_ORIGIN_NOT_ALLOWED so the constant does not restate the
mistake. Browsers hide the handshake response from page JavaScript, so
the server log is the only place either text is read.

Two coverage gaps around the same gate close with it.

testValidateHandshakeRejectsOriginHeader set no Host, so isSameOrigin
returned on its null-host guard and the test would have stayed green
through a scheme-arm or authority-loop bug. It now sends an Origin that
shares both the scheme and the authority length of Host, which leaves
the byte-by-byte comparison as the only thing that can refuse it.

QwpBrowserOriginTest gains the case where the Origin authority is a
strict prefix of Host, the shape a page on the default port produces
when it reaches QWP on another one. RFC 6454 counts the port, so that is
cross-origin, and every previously asserted rejection either has the
origin authority longer than Host or differs inside the compared bytes.
Relaxing the length equality to reject only the longer case therefore
left the whole suite green while re-opening CSWSH from any same-host web
app on a different port. The empty-authority case joins it, since the
byte loop never runs there and only the explicit bound rejects it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
The browser SERVER_INFO capability byte was only ever asserted clear.
Every assertBrowserHandshakeServerInfo call site passed expectDurableAck
false, and the enabled-registry test pinned the negotiated state and the
101 echo without reading the frame, so replacing the durableAckEnabled
argument of writeBrowserServerInfoFrame with a constant left the whole
suite green while every browser was told durable ACK was unavailable and
never entered durable mode. The frame is the browser's only carrier for
that verdict.

Give the helper an optional registry and add the one case that drives an
enabled registry through onHeadersReady and reads the bit back.

The four character-class terms of the isSameOrigin byte loop had the same
problem. The userinfo case exited at the length check, 24 bytes against
19, so the '@', '?', '#' and control-byte rejections decided nothing and
could all be deleted unnoticed. Add equal-length pairs that force each
term to be the one that rejects.
The RESPONSE_WEBSOCKET_PROTOCOL_DURABLE_ACK comment said the token is
echoed only when the client offered the subprotocol and the registry is
enabled. The code echoes it whenever it was offered, on purpose, and
three other places say so: the inline comment above the echo, the javadoc
on SERVER_INFO_CAP_DURABLE_ACK, and a test that asserts the echo happens
with the registry disabled. A maintainer who trusted the constant would
re-add the gate and break every browser connection, because a browser
fails the whole connection when it offered a subprotocol and the 101
names none. State what the code does instead.

The durableAckWebSocketProtocolEnabled local only renamed
durableAckWebSocketProtocolRequested, and its suffix contradicted the
comment directly above it, which says the token carries the dialect and
not the capability. Use the requested flag at both call sites.

Two handshake overloads lost their last production caller when the
session-cookie parameter landed and were left behind by the sweep that
removed the previous pair. Fold their javadoc into the surviving forms
and move the two remaining test call sites across.

Sort WEBSOCKET_PROTOCOL_QWP_DURABLE_ACK after WEBSOCKET_GUID; the public
constant block was in exact order before this branch.

Point the WEBSOCKET_KEY javadoc at this repository's own path allowlist
rather than at a per-value rule that only exists in the enterprise
gitleaks config.
The pointer was ambiguous about which repository's .gitleaks.toml carries
the exemption. This repository uses a blanket src/test path allowlist;
the enterprise repository scans its test sources and needs a per-value
entry.
Restore alphabetical member order in the QWP header, fragmented
upgrade, and handshake processor tests.
@glasstiger

Copy link
Copy Markdown
Contributor Author

Tandem review completed at level 3 together with questdb/questdb-enterprise#1175.

Findings

  • Critical: none open
  • Moderate: none open
  • Minor: none open

The browser WebSocket authentication/origin handling, durable-ACK negotiation and polling, SERVER_INFO framing, compression negotiation, caller impacts, resource handling, concurrency behavior, and test efficacy were reviewed across both repositories.

Coverage gate: pass (0 admitted open coverage gaps). git diff --check passes, and the companion Enterprise durable-ACK retention coverage issue was fixed in questdb/questdb-enterprise@0eabda87d1d6569831e8745dae84f4b24bb5b7a3.

Submodule provenance from the Enterprise PR: questdb is OFF-DEFAULT — in scope. Reviewed OSS head: 78f0be76b1cc3650c9f2b46bbde24ac070d0a634.

Verdict: approve.

@glasstiger

Copy link
Copy Markdown
Contributor Author

/azp run macwin

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

glasstiger and others added 2 commits September 9, 2026 22:29
Three guards on the browser negotiation paths had no test that failed
when they were removed. Each new test now does, and each was checked
against the mutation it is meant to catch.

The poll-reject arm of handleBinaryMessage records a deferred
STATUS_PARSE_ERROR when the pending-ACK flush blocks, so the resumed
connection re-sends the refusal. The structurally identical arm at the
tail of the same method already had a test; the poll arm did not.
QwpIngressUpgradeProcessorResumeRecvTest now drives a refused poll on a
blocked socket through the existing fault-injection seam and pins both
the deferred status and the sequence.

onHeadersReady resolves the accept-encoding preference with the URL
parameter winning over the header, which stops a reverse proxy from
overriding the codec a browser asked for. Both arguments are
Utf8Sequence, so swapping them at the call site compiles silently, and
the unit test around negotiateAcceptEncoding pins the function rather
than the call. QwpBrowserNegotiationWireTest now upgrades with both
carriers naming different levels, in both directions, so the result
cannot be read as a higher-wins or lower-wins rule.

writeServerInfoFrame subtracts the compression trailer from the body cap
it hands writeServerInfo, which is what keeps the trailer's two bytes
inside the send buffer once the ids fill that cap exactly. Dropping the
term writes past the end and no round-trip assertion sees it.
QwpServerInfoFrameTest now sweeps every buffer size from below the
minimum to past the natural frame size with a guard region behind the
declared size.

Along the way the tests stop reinventing each other. QwpWireTestFixtures
gains the durable-ack poll frame builder that QwpIngressAckLeapfrogTest
hand-rolled, its handshake helper takes extra request headers and uses
the WEBSOCKET_KEY constant it already exposes instead of recomputing the
nonce, and readServerInfo asserts through assertQwpMessageKind rather
than a weaker inline shape check. Two helper pairs collapse: the
subprotocol handshake assertion subsumes the durable-ack one, whose
eight callers now also pin that a header-only request draws no
subprotocol echo, and a boolean frame scan gives way to the indexed scan
it duplicated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7WsfrGT2RPKDSYkBhwdCR
@ideoma

ideoma commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

[PR Coverage check]

😍 pass : 207 / 215 (96.28%)

file detail

path covered line new line coverage
🔵 io/questdb/cutlass/http/HttpCookieHandler.java 0 1 00.00%
🔵 io/questdb/cutlass/http/HttpCookieHandlerImpl.java 7 8 87.50%
🔵 io/questdb/cutlass/qwp/server/egress/QwpEgressUpgradeProcessor.java 45 49 91.84%
🔵 io/questdb/cutlass/qwp/server/QwpIngressUpgradeProcessor.java 52 53 98.11%
🔵 io/questdb/cutlass/qwp/server/QwpIngressHttpProcessor.java 92 93 98.92%
🔵 io/questdb/cutlass/http/HttpServerConfigurationWrapper.java 1 1 100.00%
🔵 io/questdb/PropertyKey.java 1 1 100.00%
🔵 io/questdb/PropServerConfiguration.java 2 2 100.00%
🔵 io/questdb/cutlass/qwp/protocol/QwpMessageHeader.java 6 6 100.00%
🔵 io/questdb/cutlass/http/DefaultHttpServerConfiguration.java 1 1 100.00%

@glasstiger

Copy link
Copy Markdown
Contributor Author

/azp run macwin

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@glasstiger

Copy link
Copy Markdown
Contributor Author

Tandem review completed at level 3 across questdb/questdb-enterprise#1175 and #7531.

Reviewed revisions: Enterprise d977d89bce73521b01b90424e76f9953bd5c1512, OSS 38ac0f1604f830cc92ba51c74a617674545102c7.

Findings

  • Critical: none
  • Moderate: 4
  • Minor: 3

Verified green: mvn compile test-compile on both repos; 142 OSS browser/negotiation tests; 58 OSS QWP wire and ack tests; 6 Enterprise browser-session and TLS-origin tests; node --check, python -m py_compile, YAML parse and git diff --check. All zero failures.

The security core holds up under independent re-derivation: the same-origin check, all four TLS-flag combinations, every handshake size-versus-writer byte path, the SERVER_INFO body cap against writeUtf8Truncated, the raw Set-Cookie bytes against HttpResponseSink.setCookie, and the durable-ACK poll ordering against the unresolved-sequence gate.

Omitted as unverified

Three independent passes raised the same candidate: on /read/v1, a request carrying X-QWP-Accept-Encoding but no qwp_accept_encoding URL parameter gets a compressed wire with CAP_COMPRESSION clear and no codec/level trailer. It is omitted because no supported producer exists. Nothing in either repo injects that header, browsers cannot set it, and whether the JavaScript client always emits the URL parameter is not checkable from this tree. The behaviour is deliberate and pinned by testHeaderNegotiatedCompressionLeavesCapCompressionClear. What survives from it is M1 below.

Moderate

M1 [OSS] Javadoc claims a protection the keying does not provide.

core/src/main/java/io/questdb/cutlass/qwp/server/egress/QwpEgressUpgradeProcessor.java:301-305 contrasts keying CAP_COMPRESSION off "the URL parameter's presence" against "which value won", and states the latter "would let an injected header compress the wire while the browser was told nothing". Those are the same predicate: negotiateAcceptEncoding returns the URL value exactly when it is non-null, so "URL won" and "URL present" are identical conditions. The state the sentence warns about is reachable under the implemented keying whenever the parameter is absent and the header is present, because state.setCompression at line 528 is keyed on the negotiated result while line 486 is keyed on the parameter. Either restate the sentence to name the residual honestly, or close the gap.

M2 [ENT] The e2e harness cap is below the budget its own docstring enumerates.

questdb-ent/e2e/lib/javascript_client.py:92-101 says "Above the driver's own worst case, not below it" and then lists 60s, 60s, 60s, 10s and 10s. Those sum to 200 against a 180s cap. The retention monitor overlaps waitForDurable, but the reconnect budget at javascript_durable_ack.mjs:139 does not overlap ackTimeoutMs at line 133, so a run that reconnects once can exceed 180s. That reproduces exactly the generic-TimeoutError masking the cap was raised to eliminate.

M3 [ENT] The retention comment claims a tolerance the assertion does not implement.

questdb-ent/e2e/lib/javascript_durable_ack.mjs:75-82 says the peak and low pair "survives an appearance read that raced a client still writing its second record". With a seed of 1 and a later sample of 2, peak becomes 2 and low stays 1, so the low == peak assertion at tests/test_javascript_client_durable_ack.py:102-110 fails, and its message reads "shrank from 2 to 1" for a journal that grew. Whether one sendTables call can produce more than one .sfa record is a property of the JavaScript client and is not checkable from this tree, so this is reported as the contradiction it is, not as a proven flake.

M4 [OSS] Coverage gap: the browser-only durable-ACK carrier is never driven over a real socket.

Every Sec-WebSocket-Protocol request-header offer in the test tree is set on a MockHttpRequestHeader in QwpIngressUpgradeProcessorOnHeadersReadyTest, or is a containsWebSocketProtocol unit assertion, or asserts the response echo. The qwp_browser_handshake URL carrier is driven end to end by QwpBrowserNegotiationWireTest, precisely because route matching matters; the subprotocol carrier is not, and it is the only carrier a browser has for this capability. If anything between the socket and the header lookup dropped it, the server would echo nothing, the browser would fail the whole connection per the WHATWG algorithm, and every unit test would stay green.

Not Critical: X-QWP-Request-Durable-Ack uses the identical lookup and is exercised over a real socket by the pinned Java client, so the mechanism is proven, and the failure mode is loud rather than silent data loss. The cheap fix is an extraHeaders parameter on QwpWireTestFixtures.performWriteHandshake, which the read-side fixture already has.

Minor

  • Member ordering in the two new files. questdb-ent/src/test/java/com/questdb/acl/QwpEnterpriseBrowserSessionTestSupport.java:238 places readCrLf before openWebSocket at line 279, and loginAndAssumeServiceAccount at line 247 sits between two private statics. core/src/test/java/io/questdb/test/cutlass/qwp/QwpBrowserNegotiationWireTest.java:265 places readCapabilities before assertNegotiatedZstdLevel. Smaller insertions in QwpIngressUpgradeProcessorOnHeadersReadyTest, WebSocketHandshakeTest, QwpEgressCompressionTest, QwpIngressAckLeapfrogTest and DynamicPropServerConfigurationTest land in runs that are already unsorted at base.
  • A private helper restates an existing one. QwpIngressHttpProcessor.java:703 adds startsWithIgnoreCaseAscii, whose comparison is what Utf8s.startsWithLowerCaseAscii at Utf8s.java:1065 already performs. Adoption needs two Utf8String constants. The companion toLowerAscii at line 715 has no accessible equivalent and is still needed by the authority loop, which fuses the case-fold with its character-class rejections.
  • A timing floor uncoupled from the constant it claims to pin. test_javascript_client_durable_ack.py:112 asserts at least 500ms while the throttle is set to 3000ms at line 21, as two independent literals. The clock starts after an unbounded journal-appearance wait, so the floor does not pin the throttle in either direction.

Coverage gate

Pass. Zero admitted Critical coverage gaps, one admitted Moderate gap (M4).

Coverage is unusually strong. Every branch enumerated independently has a dedicated test, including all six equality terms of the poll-frame matcher with the superset and length relaxations pinned, all five character-class terms of the origin loop with length-matched forged hosts so the length guard cannot decide the case first, both directions of the compression and batch-row carrier precedence, and the exact Set-Cookie attribute string on both the 101 and the 421.

Scope notes

Submodule provenance: questdb is OFF-DEFAULT, in scope, present only on origin/ia_node_qwp. The nested java-questdb-client pointer did not move and is out of scope.

The Enterprise pull request contains no Enterprise production code; all five changed Java files are tests. The JavaScript lane is inert for the driver and the pipeline, but the two Python files are collected and imported by the ordinary Enterprise e2e job, so a syntax or import error there would go red on this pull request. Both compile, the driver parses, and both pipeline files parse.

Findings split: 4 in-diff, 0 out-of-diff breakage.

Verdict

ENT: approve. OSS: approve. Both gates pass. The four Moderate items are worth fixing and none of them blocks the merge.

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