Skip to content

/jaws/.tail/<key> honours a key the claim path already spent #397

Description

@linkdata

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions