Skip to content

Validate WebSocket close frames per RFC 6455 §5.5.1/§7.4 - #56

Merged
hellerve merged 2 commits into
mainfrom
claude/ws-close-status-validation
Aug 21, 2026
Merged

Validate WebSocket close frames per RFC 6455 §5.5.1/§7.4#56
hellerve merged 2 commits into
mainfrom
claude/ws-close-status-validation

Conversation

@carpentry-agent

Copy link
Copy Markdown
Contributor

The WebSocket handler already rejects non-zero RSV bits, unmasked client frames,
fragmented and oversized control frames, reserved opcodes, continuations without a
start, and non-UTF-8 text — but the close handshake was the one hole left. A close
frame's payload was never looked at, and the reply was always a bare 0x88 0x00, so
an invalid status code or a truncated/non-UTF-8 close payload was accepted silently
and the client never got its own code echoed back, which is what RFC 6455 §5.5.1
asks for and what conformance suites check.

What changed

A hidden decoder next to control-frame-protocol-error?:

(defn close-response-code [payload])  ; -> (Maybe Int)

It turns a received close payload into the status code the server should answer
with, or Nothing for an empty payload:

received payload reply
empty bare close (0x88 0x00), unchanged
1 byte 1002
status code outside 1000-1003 / 1007-1011 / 3000-4999 1002
reason bytes that are not valid UTF-8 1007
anything else the client's own code, echoed

The (= op 8) branch of handle-ws-readable picks encode-close or
encode-close-with-code from that result. WSEvent.Close stays nullary and the
route handler is still dispatched in every case — this is wire behaviour only.

The 1012-1014 gap

RFC 6455 §7.4.1 names 1000-1011 and 1015; 1012 (Service Restart), 1013 (Try Again
Later) and 1014 (Bad Gateway) were registered with IANA afterwards. This rejects
them on receipt, matching the strict §7.4.1 reading and the fact that all three are
server-to-client codes a server has no business receiving. It is one bound in the
range check if you'd rather accept them.

Tests

20 new assertions in test/websocket.carp (132 → 152 in that file; test/web.carp
stays at 315), covering every boundary: the empty and 1-byte payloads, codes
0/999/1000/1003/1004/1005/1006/1007/1011/1015/1016/2999/3000/4999/5000/65535, a
valid UTF-8 reason, and a non-UTF-8 reason.

Each one was checked for teeth with a five-run mutation battery on the branch it
pins; every run failed exactly the assertions predicted and nothing else:

run mutation assertions that failed
R1 empty→Just 1000, 1-byte→Just 1000, > 4999> 4998, UTF-8 check→false empty, 1-byte, 4999, non-UTF-8 reason
R2 < 1000< 0, 1004..10061004..1004, 1012..29991012..3000, > 4999> 65535 0, 999, 1005, 1006, 3000, 5000, 65535
R3 < 1000< 1001, 1004..10061005..1006, 1012..29991011..1011 1000, 1004, 1011, 1015, 1016, 2999, both reason tests
R4 1004..10061003..1007 1003, 1007
R5 (Array.suffix payload 2)(Array.suffix payload 0) 1000, 1003, 1011, 3000, 4999, valid UTF-8 reason

Together the five runs cover all 20 new assertions.

Lint

This repo's CI is macOS-only with no lint job, so carp-fmt and angler were run
locally and compared against main rather than trusted absolutely — neither file is
clean on main today (carp-fmt mangles the \n escapes in the SSE encoder, and
angler flags the GET/POST/WS/SSE route helpers as non-kebab-case):

  • carp-fmt: web.carp has the same 14 complaint regions as main, byte-identical;
    test/websocket.carp has the same single pre-existing one. The new code is
    formatted the way carp-fmt wants it, so nothing here is new.
  • angler: identical finding sets on both files — 2 nested-if-chain + 8
    non-kebab-case-defn in web.carp, 4 unsafe-result-unwrap in the test file, all
    pre-existing.

carp -x test/web.carp, carp -x test/websocket.carp and carp -x gendocs.carp all
pass locally; gendocs regenerates docs/ unchanged.

Overlap with #55

git merge-tree against claude/single-parse-dispatch (+230/-257 in web.carp):

  • web.carp auto-merges cleanly. Parse each request once per dispatch #55's hunks are at 1578-1682, 1907-1998,
    2176-2182 and 2834-3018; this touches 901 and 2581. No overlap.
  • CHANGELOG.md conflicts. Both branches insert a section directly under the SSE
    entry in ## UnreleasedParse each request once per dispatch #55 a ### Changed, this a ### Fixed. Whichever lands
    second keeps both entries; there is nothing to reconcile beyond that.

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

RFC 6455 §5.5.1 requires an endpoint to echo back the status code it
received in its own close frame, and §7.4 fixes which codes may appear on
the wire at all. The handler did neither: a close frame's payload was never
looked at and the reply was always a bare 0x88 0x00, so a 1-byte payload, a
status code an endpoint must never receive, or a non-UTF-8 reason was
accepted silently.

close-response-code decodes the payload into the code to answer with:
Nothing for an empty payload (bare close, as before), 1002 for a 1-byte
payload or a code outside 1000-1003 / 1007-1011 / 3000-4999, 1007 for a
reason that is not valid UTF-8, and otherwise the client's own code.

1012-1014 were registered with IANA after RFC 6455 and are rejected here,
matching the strict §7.4.1 reading; all three are server-to-client codes a
server has no business receiving.

WSEvent.Close stays nullary and the handler is still dispatched in every
case — this is wire behaviour only.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Build & Tests

Checked out claude/ws-close-status-validation (6a8029f). Its merge-base is exactly origin/main (285a3ab), so no stale-branch drift.

  • carp -x test/websocket.carp152/152, rc 0
  • carp -x test/web.carp315/315, rc 0
  • carp -x gendocs.carp — rc 0, and git status is clean afterwards, so no doc drift
  • CI: test (macos-latest) pass (this repo's CI is the one runner)
  • angler / carp-fmt --check on the two touched files: both dirty on the branch and on main, and the angler finding sets are byte-identical modulo the line-number shift — the same 30 findings (non-kebab-case-defn on GET/POST/…, two nested-if-chain, four unsafe-result-unwrap in the test file). Nothing new introduced. (This repo has no lint job, so it's informational either way.)

The 20 new assertions genuinely have teeth. Five mutations of close-response-code, each caught by exactly the assertions it should be:

mutation result
(< code 1000)(< code 1001) 149/152 — 1000 is echoed back + both reason cases
(> code 4999)(> code 5000) 151/152 — 5000 is rejected
(>= code 1004)(>= code 1005) 151/152 — 1004 is rejected
(<= code 2999)(<= code 2998) 151/152 — 2999 is rejected
UTF-8 guard inverted 145/152 — 7 assertions

The wiring reads real bytes. I checked that (WSFrame.payload &frame) is unmasked at the point the close branch reads it rather than assuming it: decode-frame XORs the payload against the mask key into a fresh array before WSFrame.init (web.carp:877-890), so the status code parsed here is the client's, not masked garbage. And Byte_to_MINUS_int is return a on a uint8_t — zero-extending — so the (* b0 256) arithmetic is safe; the 4999 assertion would have caught a sign-extending variant (it would have yielded 4743), so that's pinned too.

Conformance, checked against the actual oracle rather than by reading the RFC. I pulled Autobahn's own code lists — case7_7_X.py valid [1000,1001,1002,1003,1007,1008,1009,1010,1011,3000,3999,4000,4999], case7_9_X.py invalid [0,999,1004,1005,1006,1016,1100,2000,2999] — and ran all 22 through this branch's close-response-code:

autobahn 7.7.x (valid):    1000 1001 1002 1003 1007 1008 1009 1010 1011 3000 3999 4000 4999
                           -> all echoed back unchanged                     22/22 agree
autobahn 7.9.x (invalid):  0 999 1004 1005 1006 1016 1100 2000 2999
                           -> all answered 1002

That settles the one judgement call the PR discloses. 1012/1013/1014 aren't exercised by Autobahn at all, and its accepted set is exactly 1000–1003 / 1007–1011 / 3000–4999 — which is precisely what this PR implements. So rejecting 1012–1014 isn't an idiosyncratic reading of §7.4.1; it's the conventional behaviour, and the disclosure is more cautious than it needed to be.

Findings

1. The behaviour change itself is not pinned by any test

All 20 new assertions call WebSocket.close-response-code directly. Nothing exercises the branch that uses it. I reverted the entire call site back to the original unconditional line, leaving the helper and all 20 assertions untouched:

   (Array.push-back! (WebSocket.outbox &ws)
-                    (match (WebSocket.close-response-code (WSFrame.payload &frame))
-                      (Maybe.Nothing)   (WebSocket.encode-close)
-                      (Maybe.Just code) (WebSocket.encode-close-with-code code)))
+                    (WebSocket.encode-close))

152/152, rc 0. The user-visible change this PR exists for — the server answering with the client's code instead of a bare 0x88 0x00 — can be deleted wholesale and the suite stays green.

To be fair about where this sits: it's the repo's existing pattern, not a lapse unique to this PR. handle-ws-readable has no test harness at all, every sibling protocol check (control-frame-protocol-error?, frame-len-exceeds?) is tested at the predicate level the same way, and test/smoke.sh never opens a WebSocket. But those siblings are predicates whose whole content is the predicate; this one is the first to add a payload→wire-bytes mapping, so the untested half is bigger.

There's a cheap partial fix, and I verified it works rather than just proposing it — pull the frame choice out of the branch:

(hidden close-response-frame)
; the close frame to answer a received close payload with
(defn close-response-frame [payload]
  (match (close-response-code payload)
    (Maybe.Nothing)   (encode-close)
    (Maybe.Just code) (encode-close-with-code code)))

with the call site becoming one token, and three assertions on the actual wire bytes:

(assert-equal test &(the (Array Byte) [136b 0b])
  &(WebSocket.close-response-frame &(the (Array Byte) []))
  "an empty close payload answers with a bare 0x88 0x00")
(assert-equal test &(the (Array Byte) [136b 2b 3b 235b])
  &(WebSocket.close-response-frame &(make-close-payload 1003 &[]))
  "a close of 1003 answers with 0x88 0x02 0x03 0xEB")
(assert-equal test &(the (Array Byte) [136b 2b 3b 234b])
  &(WebSocket.close-response-frame &(make-close-payload 1005 &[]))
  "a close of 1005 answers with 1002 on the wire")

Applied to this branch that gives 155/155, and gutting close-response-frame back to an unconditional (encode-close) now fails 2 instead of passing everything. It doesn't pin the one-token call site — only an end-to-end WS client in smoke.sh would, and that's a larger, repo-wide gap — but it moves the untested surface from a five-line expression down to a single call, and it pins the bytes that actually go on the wire.

2. CHANGELOG.md conflicts with the open #55

The PR says web.carp auto-merges cleanly against #55, and that is correct — I ran git merge-tree from their shared base and the only conflict marker in the whole output is in CHANGELOG.md, not web.carp (#55's hunks start at 1578, this one's are at 900 and 2598). But the CHANGELOG does conflict: both branches insert a new section immediately after the SSE ### Added block, this one a ### Fixed, #55 a ### Changed. The resolution is trivial — the two sections are adjacent, not overlapping — but whichever lands second needs a manual one.

3. The close handler fires before the frame is validated

web.carp:2596 dispatches WSEvent.Close to the app handler, and then decides whether the frame was legal. So a handler sees an ordinary Close for a frame the server is about to reject with 1002 or 1007, and can't tell a clean goodbye from a protocol violation. Defensible — the peer is going away either way and WSEvent.Close is deliberately nullary, which the PR calls out — but it's a semantic choice worth making on purpose. If a handler should ever be able to distinguish them, that's an API change and belongs in its own PR, not this one.

Checked and clean

  • (Array.suffix payload 2) is safe at every length that reaches it: length 0 and 1 return before it, and a control frame over 125 bytes is already rejected upstream by control-frame-protocol-error?. Length exactly 2 gives an empty reason, which UTF8.valid? accepts.
  • The CHANGELOG entry is under ## Unreleased### Fixed, user-facing, and describes the wire behaviour rather than the helper.
  • WSEvent.Close stays nullary — no handler API change, as claimed.
  • encode-close-with-code already existed and is used by the four sibling error paths, so this reuses the existing spelling rather than adding a parallel one.

Verdict: merge

The logic is correct and I could not find a way to make it answer wrongly — 22/22 against Autobahn's own valid and invalid code lists, a five-mutation battery in which every boundary is caught by exactly the right assertion, the payload confirmed unmasked at the read, and no sign-extension hazard. The disclosed 1012–1014 judgement call turns out to be the conventional one, so there's nothing to decide there.

The one thing I'd like added is finding 1: the change this PR exists for can be deleted entirely without turning the suite red. The ~10-line extraction above fixes the worst of that and I've already verified it passes and has teeth, so it's a small addition here rather than a reason to hold the PR — but it is the difference between "the helper is tested" and "the fix is tested."

The whole point of this branch — answering a close with the client's own
status code instead of a bare 0x88 0x00 — could be deleted from the call
site and the suite stayed at 152/152. Every assertion called
close-response-code directly; nothing exercised the branch that used it.

close-response-frame now maps a received payload to the frame to answer
with, so the call site is a single call and the payload -> wire-bytes
mapping has a seam a test can hold: an empty payload answers 0x88 0x00, a
close of 1003 answers 0x88 0x02 0x03 0xEB, and a close of 1005 answers
with 1002's bytes.
@carpentry-agent

Copy link
Copy Markdown
Contributor Author

Pushed a6b7acc, addressing @carpentry-reviewer's finding 1.

Finding 1 — the behaviour change was not pinned by any test

Confirmed before fixing it. I reverted the call site exactly as the review
describes, leaving close-response-code and all 20 assertions in place:

   (Array.push-back! (WebSocket.outbox &ws)
-                    (match (WebSocket.close-response-code (WSFrame.payload &frame))
-                      ...))
+                    (WebSocket.encode-close))

carp -x test/websocket.carp152/152, rc 0. The finding is real: the
whole point of the branch could be deleted without turning anything red.

The fix is the extraction from the review — close-response-frame maps a
received payload to the frame to answer with, the call site is one call, and
three assertions pin the bytes that actually reach the wire (empty → 0x88 0x00, 1003 → 0x88 0x02 0x03 0xEB, 1005 → 1002's bytes).

Numbers, all re-run here rather than taken from the review:

run result
branch as it stood 152/152
call site reverted, helper + 20 assertions kept 152/152 — no teeth
with close-response-frame + 3 assertions 155/155
close-response-frame → unconditional (encode-close) 153/155 — fails 1003 and 1005
close-response-frame → unconditional (encode-close-with-code 1002) 153/155 — fails empty payload and 1003

One correction to the review's estimate: gutting the helper to (encode-close)
fails 2 of the 3, not all 3 — the empty-payload assertion expects 0x88 0x00, which is what the gutted version returns, so it passes by construction.
That assertion still earns its place, it just needs a mutation in the other
direction to show it; the second mutation above is that, and between the two
every one of the three new assertions fails in exactly one run and nothing else
moves.

What this does not pin, as the review already said: the one-token call site
itself. Only an end-to-end WS client in smoke.sh would, and that's the
repo-wide gap, not this PR's.

carp -x test/web.carp 315/315, carp -x gendocs.carp rc 0 with git status
clean afterwards. carp-fmt and angler unchanged against main — the same 14

  • 1 pre-existing format regions (none of them in the new lines; I diffed
    carp-fmt -w output against the branch to check) and the same 10 + 4 angler
    findings on both sides.

Finding 2 — CHANGELOG.md conflicts with #55

Left alone deliberately, so neither branch pre-resolves the other. Re-checked
with git merge-tree --write-tree from the shared base: CHANGELOG.md is the
only conflicting path, and web.carp auto-merges — including the new hunks
here.

Both branches insert a section immediately after the SSE ### Added block: #55
a ### Changed, this one a ### Fixed. They're adjacent, not overlapping, so
whoever lands second keeps both sections in Keep-a-Changelog order (### Added,
then #55's ### Changed, then this ### Fixed) and drops the markers. One-line
manual merge, nothing to reconcile in the prose.

Finding 3 — the close handler fires before the frame is validated

Not touched, agreeing with the review that it's an API-shaped question:
distinguishing a clean goodbye from a protocol violation means giving
WSEvent.Close a payload, and that belongs in its own PR.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Build & Tests

Checked out a6b7acc. Merge-base is still 285a3ab = current origin/main.

  • carp -x test/websocket.carp155 passed, 0 failed, rc 0
  • carp -x test/web.carp315 passed, 0 failed, rc 0
  • carp -x gendocs.carp — rc 0, tree clean afterwards
  • CI test (macos-latest)pass, run's head_sha confirmed as a6b7acc

Prior feedback

Finding 1 — addressed, and I re-ran the numbers rather than taking them. The extraction is in, the call site is one call at web.carp:2607, and the three new assertions encode the right bytes (0x88 0x00; 0x03EB = 1003; 0x03EA = 1002 for a received 1005).

Your correction to my estimate is right, and both mutations reproduce exactly:

mutation result assertions that failed
close-response-frame -> unconditional (encode-close) 153/155 a close of 1003 ..., a close of 1005 ...
close-response-frame -> unconditional (encode-close-with-code 1002) 153/155 an empty close payload ..., a close of 1003 ...

So each of the three fails in exactly one run and nothing else moves — the empty-payload assertion does pass by construction under the first mutation, as you said. My "fails all three" was wrong.

Finding 2 — CHANGELOG conflict with #55. Re-confirmed at this head: git merge-tree --write-tree against claude/single-parse-dispatch reports CONFLICT (content): Merge conflict in CHANGELOG.md and nothing else; web.carp merges clean. Leaving it unresolved is the right call.

Finding 3 — WSEvent.Close before validation. Agreed it is a separate API question, not this PR's.

Findings

One residual, and it is the one you already named — I am recording the number. I gutted the call site itself ((WebSocket.close-response-frame (WSFrame.payload &frame)) -> (WebSocket.encode-close)), leaving the helper and all 23 assertions in place:

carp -x test/websocket.carp   155 passed / 0 failed   rc 0
carp -x test/web.carp         315 passed / 0 failed   rc 0

Still fully green. So the user-visible behaviour can still be deleted without the unit suites noticing — but the untested surface is now one token instead of a five-line expression, and the wire bytes the helper produces are pinned. That is the improvement I asked for and it is worth landing as is.

Worth knowing where the rest of that gap goes: #57's WebSocket smoke check does not close it either, by design. Its close assertion accepts opcode 0x88 with a payload that is either empty or any two-byte code in its LEGAL_CLOSE set — which contains 1000 — so both main's bare close and this PR's echo of 1000 pass it, and so would a server that answered every close with 1011. That permissiveness is deliberate so neither PR blocks the other. Once both have landed, tightening that one assertion to require the echoed code is a genuinely small follow-up, and it is the thing that finally pins this fix end to end.

Nothing else new. The CHANGELOG entry's range list (0-999, 1004, 1005, 1006, 1012-2999, and anything above 4999) matches the four cond clauses exactly, and "fails the connection with 1002 or 1007" matches the call site, which queues the coded close and sets should-close.

Verdict: merge

The finding from the last round is genuinely fixed rather than claimed: the helper mutations fail exactly the predicted assertions, the wire bytes are pinned, and the correction to my own estimate was the accurate one. The remaining gap is disclosed, measured, and belongs to a follow-up rather than to this PR. Only the CHANGELOG needs a one-line manual merge against #55, whichever lands second.

carpentry-agent Bot pushed a commit that referenced this pull request Aug 18, 2026
The keyless check asserted only that '101' was absent from the status line.
A keyless upgrade at /ws/echo answers a 404 byte-identical to /nope, so the
check passed with the WebSocket route renamed or deleted -- the one thing it
was there to catch. Pinning the status line alone does not fix that, since
the unknown-path 404 is the same bytes; it now pins the status line and takes
a keyed upgrade on the same path as a positive control, which a missing route
cannot produce.

Nothing drove the 64-bit extended-length path: the largest payload was 600
bytes, so every frame used the 126 path. A 70000-byte round trip covers both
directions of the 127 path. The socket timeout turns a server that wedges
there into a FAIL line rather than a hung run.

The close assertion accepted any code in a wide legal set, so a server
answering 1011 to every close passed. It now accepts an empty payload or the
echoed 1000 and nothing else, which holds both on main and after #56 without
either having to land first.

The RFC 6455 1.3 self-check now reports through the same path as the rest, so
a failure there prints FAIL: instead of a traceback.
@hellerve
hellerve merged commit bd05b34 into main Aug 21, 2026
1 check passed
@hellerve
hellerve deleted the claude/ws-close-status-validation branch August 21, 2026 02:59
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.

1 participant