Skip to content

fix(server): reject ill-formed UTF-8 in inbound frames with 1007 - #390

Merged
ThomasK33 merged 3 commits into
mainfrom
fix/inbound-utf8-strict
Nov 1, 2026
Merged

ThomasK33 merged 3 commits into
mainfrom
fix/inbound-utf8-strict

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

The operator goal is to clear the open follow-up issues in this repository (#385 now) with small reviewed PRs. This PR takes the inbound part of #385 item 6. Items 7 and 8 are in #386.

RFC 6455 requires an endpoint to close a connection with 1007 when a text frame or a close reason is not valid UTF-8. The plugin's inbound check, server/utils.lua is_valid_utf8, took any lead byte from C0 to FF with the right number of continuation bytes. So it let through overlong forms, UTF-16 surrogates, code points above U+10FFFF, and the bytes F5 to FF. The plugin then decoded the request and ran it. The outbound side already has a strict decoder, claudecode.utils.utf8_sequence_length (behind valid_utf8, since #378). This PR makes the inbound check use that decoder.

The outbound part of item 6 does not change: a buffer name with invalid bytes still reaches Claude as U+FFFD, as lua/claudecode/server/README.md documents.

What changed

  • lua/claudecode/server/utils.lua: is_valid_utf8 walks the text with utils.utf8_sequence_length and returns false at the first ill-formed sequence. frame.parse_frame (text frames and close reasons) and frame.validate_frame call it as before.
  • tests/unit/server/frame_spec.lua: seven text-frame cases and one close-reason case that must get 1007, and one frame with the first and last code point of each UTF-8 length that must still parse.
  • lua/claudecode/server/README.md: the 1007 invariant names what counts as ill-formed.
  • .agents/skills/verify-diff-lifecycle/covered.lock: re-recorded. Only the server/utils.lua file hash changed, with no covered block or call site. A rejected frame closes the connection through the same on_disconnect path as any other close, and the model already lets a client disconnect at any step.

Scope

#385 item 6, inbound only. No change to what the plugin sends.

Blast Radius

A client that sends ill-formed UTF-8 now gets Close 1007, where the plugin used to run its request. Claude Code cannot send such bytes: it serializes each message with JSON.stringify, which escapes a lone surrogate as \uD800, and its WebSocket sends a JS string as UTF-8. The real CLI run below shows that its non-ASCII text still goes through.

Verification

Run Code Result
busted tests/unit/server/frame_spec.lua (red) 29e3185, the tests on main 8 failures: each ill-formed case gets no 1007
Same (green) this PR 25 successes. mise run test: 908 successes, 0 failures
Raw WebSocket client, one masked text frame each, real Neovim 0.12.3 (verify-claudecode-nvim harness) main (17f53b3) ED A0 80, C0 AF, F4 90 80 80 and F5 80 80 80 each get a getDiagnostics answer. The plugin ran the call with the ill-formed path
Same this PR each of those four gets Close frame, code 1007. A frame with F0 9F 98 80 (😀) still gets its answer, and the plugin keeps its other client
Real Claude CLI 2.1.312, claude -p --model haiku with the plugin as an MCP server over the CLI's own WebSocket transport, asked to call getDiagnostics for file://…/naïve-日本-😀.txt main the plugin logs getDiagnostics arguments: {uri=50 bytes} and finds the open buffer. Claude replies ünïcode ✓ diagnostic, the diagnostic set on that buffer. The CLI closes with 1000
Same this PR the same: uri=50 bytes, reply ünïcode ✓ diagnostic, close 1000
10 MB through is_valid_utf8 in LuaJIT 2.1, best of 3 main / this PR ASCII 10.6 / 10.6 ms. Mixed text 16.2 / 13.7 ms. CJK 31.8 / 27.2 ms
verify-diff-lifecycle/check.sh this PR (15afbfd) PASS. main: pass (89205 distinct states found), live: pass (5269 distinct states found)
How the real CLI run works

claude -p --ide does not connect to an IDE in print mode. So the script hands the CLI an --mcp-config file with an MCP server of type ws at the plugin's port. The lock file's token is in the x-claude-code-ide-authorization header. The CLI's IDE transport (ws-ide) and this one build the same WebSocket(url, { protocols: ["mcp"], headers }) and the same transport class, so the bytes on the wire come from the same code. The run uses its own HOME and CLAUDE_CONFIG_DIR under the harness state dir, and ANTHROPIC_API_KEY for one haiku request. The 50-byte uri is the UTF-8 length of file:///tmp/vcn.<id>/work/naïve-日本-😀.txt.

Part of #385


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

RFC 6455 closes a connection with 1007 on a text frame or close reason
that is not valid UTF-8. The inbound check takes any lead byte from C0 to
FF with the right number of continuation bytes, so it lets through
overlong forms, UTF-16 surrogates, code points above U+10FFFF and the
bytes F5 to FF. Seven text-frame cases and one close-reason case fail on
main. A case with the first and last code point of each UTF-8 length
passes on main and must keep passing.

Part of #385

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
The inbound check took any lead byte from C0 to FF with the right number
of continuation bytes. A text frame or close reason with an overlong
form, a UTF-16 surrogate, a code point above U+10FFFF or a byte F5 to FF
passed, and the plugin decoded and ran the request. RFC 6455 requires the
endpoint to close such a connection with 1007.

is_valid_utf8 now walks the text with utils.utf8_sequence_length, the
decoder behind the outbound valid_utf8, so both directions share one
definition of UTF-8. It is as fast as the old loop on 10 MB of ASCII and
faster on multibyte text.

Part of #385

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
…check

Only server/utils.lua changed. A stricter frame check closes the
connection of a client that sends ill-formed UTF-8, which reaches diffs
through the same on_disconnect path as any other close. The model
already lets a client disconnect at any step, and check.sh passes.

Part of #385

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T06:12:04.837753Z 15afbfd Manual request
🔒 Security Review ✅ Completed 2026-11-01T06:12:46.698823Z 15afbfd Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 15afbfd4ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 15afbfd4ea

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit bf80d11 Nov 1, 2026
5 checks passed
@ThomasK33
ThomasK33 deleted the fix/inbound-utf8-strict branch November 1, 2026 06:26
@github-actions github-actions Bot mentioned this pull request Nov 1, 2026
ThomasK33 added a commit that referenced this pull request Nov 1, 2026
…, #388, #390

The merge keeps this branch's bulk-close cases on main's ordered
bulk_closes list (#386). Main changed selection.lua, utils.lua, and
visual_commands.lua, which cannot reach diff state. check.sh passes on
the merged tree.

Part of #363

_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
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