Found during a source audit at 806bafb. Recorded as informational: no lower-trust principal was established that can hold a live key. What survives is a contract inconsistency plus a robustness bug that needs no attacker at all.
What happens
serveTailScript (serve.go:594-618) looks the Request up with jw.requests[jawsKey] and applies only the equalIP check. It never inspects the Request's lifecycle state, so a key that Jaws.UseRequest has already spent on the WebSocket claim keeps working on this one endpoint for the whole life of the connection — while /jaws/<key> and /jaws/<key>/noscript correctly refuse the same key with 404.
Two consequences, both reproduced over a real listener and a real coder/websocket connection:
- The one-shot drain returns whatever
Jid>0 attribute and class payloads are queued at that instant and removes them from the live outbound queue, so the browser never receives them. Observed: wsQueue went from 1 message to 0, and the drained body contained the queued update.
- When that drain produced bytes, a failed response write is escalated into
cancelIfCurrent (serve.go:616), cancelling the Request and tearing down the running WebSocket.
This also falsifies the endpoint's own safety comment at serve.go:602 — "The WebSocket carries all live data" does not hold when the application omits TailHTML, because then the tail was never drained.
The part with no attacker: an ordinary aborted sub-resource fetch — a user navigating away mid-load — destroys the whole Request. That is pinned as intended by TestServeHTTP_TailScript_WriteError (jaws_test.go:3299).
Why informational rather than a vulnerability: every plausible holder of a live key either already has strictly more capability (an in-path proxy reads <meta name="jawsKey"> directly and can inject script) or sits above the victim in trust (an operator reading logs). And the log channels are self-defeating — the .tail line implies TailHTML, whose parse-time fetch already disarmed the endpoint to 204; the /noscript line always records a Request that request.go:1257 has cancelled.
Simplest fix
Add the lifecycle gate its siblings already have, and stop escalating a write failure. Extend the existing condition at serve.go:605 and swap the cancel for a log:
if rq != nil && (!equalIP(remoteIP, rq.remoteIP) || rq.loadState() == reqRunning) {
rq = nil
}
...
_ = jw.Log(rq.writeTailResponse(w, b, sent))
rq.loadState() is a plain atomic read already used this way at jaws.go:259 and status.go:109, so no lock ordering changes. Gating on "not running" rather than "strictly pending" preserves the documented custom client that may dial while the initial template is still executing.
Three knock-on edits this needs:
TestServeHTTP_TailScript_WriteError (jaws_test.go:3299) asserts the cancellation at :3319 and must be inverted.
serve.go:616 is the only production caller of Jaws.cancelIfCurrent. Removing it leaves requestpool.go:410 referenced solely by its own test, and its doc comment at requestpool.go:403-409 names the tail write-error path as the reason it exists — so delete the helper with that test, or rewrite the comment.
- The self-justifying comment at serve.go:597-604 is what the new gate replaces.
Applying exactly this to a scratch copy made both reproductions fail and left the whole module suite green apart from the one test above.
Found during a source audit at 806bafb. Recorded as informational: no lower-trust principal was established that can hold a live key. What survives is a contract inconsistency plus a robustness bug that needs no attacker at all.
What happens
serveTailScript(serve.go:594-618) looks the Request up withjw.requests[jawsKey]and applies only theequalIPcheck. It never inspects the Request's lifecycle state, so a key thatJaws.UseRequesthas already spent on the WebSocket claim keeps working on this one endpoint for the whole life of the connection — while/jaws/<key>and/jaws/<key>/noscriptcorrectly refuse the same key with 404.Two consequences, both reproduced over a real listener and a real coder/websocket connection:
Jid>0attribute and class payloads are queued at that instant and removes them from the live outbound queue, so the browser never receives them. Observed:wsQueuewent from 1 message to 0, and the drained body contained the queued update.cancelIfCurrent(serve.go:616), cancelling the Request and tearing down the running WebSocket.This also falsifies the endpoint's own safety comment at serve.go:602 — "The WebSocket carries all live data" does not hold when the application omits
TailHTML, because then the tail was never drained.The part with no attacker: an ordinary aborted sub-resource fetch — a user navigating away mid-load — destroys the whole Request. That is pinned as intended by
TestServeHTTP_TailScript_WriteError(jaws_test.go:3299).Why informational rather than a vulnerability: every plausible holder of a live key either already has strictly more capability (an in-path proxy reads
<meta name="jawsKey">directly and can inject script) or sits above the victim in trust (an operator reading logs). And the log channels are self-defeating — the.tailline impliesTailHTML, whose parse-time fetch already disarmed the endpoint to 204; the/noscriptline always records a Request that request.go:1257 has cancelled.Simplest fix
Add the lifecycle gate its siblings already have, and stop escalating a write failure. Extend the existing condition at serve.go:605 and swap the cancel for a log:
rq.loadState()is a plain atomic read already used this way at jaws.go:259 and status.go:109, so no lock ordering changes. Gating on "not running" rather than "strictly pending" preserves the documented custom client that may dial while the initial template is still executing.Three knock-on edits this needs:
TestServeHTTP_TailScript_WriteError(jaws_test.go:3299) asserts the cancellation at :3319 and must be inverted.serve.go:616is the only production caller ofJaws.cancelIfCurrent. Removing it leaves requestpool.go:410 referenced solely by its own test, and its doc comment at requestpool.go:403-409 names the tail write-error path as the reason it exists — so delete the helper with that test, or rewrite the comment.Applying exactly this to a scratch copy made both reproductions fail and left the whole module suite green apart from the one test above.