security(collab): apply queued USER_CHANGES to the enqueue-time pad (cross-pad write TOCTOU)#8075
security(collab): apply queued USER_CHANGES to the enqueue-time pad (cross-pad write TOCTOU)#8075JohnMcLear wants to merge 2 commits into
Conversation
…cross-pad write TOCTOU) GHSA-6mcx-x5h6-rpw2. handleUserChanges re-read the mutable sessioninfos[socket.id].padId at apply time, while the authorization / read-only gate and the channel key were taken at enqueue time. Because per-socket messages are processed concurrently and a thrown handler does not disconnect the socket, a same-socket CLIENT_READY could swap the session's padId between enqueue and apply — redirecting a queued write onto a read-only or otherwise unauthorized pad (runtime-proven cross-pad write; a read-only share holder could overwrite the pad). Thread the enqueue-time pad id (already the padChannels channel key) into handleUserChanges as `authorizedPadId` and use it for the write instead of re-reading the session. The gate and the write now refer to the same pad, closing the TOCTOU window. Normal (non-racing) flows are unaffected because the session padId equals the enqueue-time padId. Adds a deterministic regression test that invokes handleUserChanges with the session padId already swapped to a victim pad and asserts the change lands on the authorized (argument) pad, never the victim. Verified it fails when the vulnerable read is reintroduced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR Summary by QodoFix TOCTOU cross-pad writes by binding USER_CHANGES to enqueue-time padId
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1.
|
There was a problem hiding this comment.
Pull request overview
Fixes a TOCTOU cross-pad write vulnerability in the collaboration message pipeline by ensuring queued USER_CHANGES are applied to the pad that was authorized at enqueue time (rather than a potentially mutated session padId). Adds a targeted backend regression test that reproduces the cross-pad write scenario and verifies the fix.
Changes:
- Thread the per-pad queue channel key (enqueue-time pad ID) into
handleUserChanges()and use it as the write target. - Adjust failure logging to report the enqueue-time authorized pad ID.
- Add a backend regression test to ensure queued edits cannot be redirected to a “victim” pad via a swapped session
padId.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/node/handler/PadMessageHandler.ts | Passes enqueue-time pad ID through the per-pad queue to prevent cross-pad writes in handleUserChanges(). |
| src/tests/backend/specs/crossPadWriteToctou.ts | Adds a regression test that simulates a swapped session padId and asserts changes apply only to the authorized pad. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| // authorizedPadId = the pad authorized at enqueue time (NOT the swapped | ||
| // session padId). | ||
| await padMessageHandler.handleUserChanges(socket, message, authorizedId); |
Code Review by Qodo
1. Enqueue padId still racy
|
…-pad write TOCTOU Addresses Qodo review on #8075: the first cut used the padChannels channel key as the write target, but that key was still read from thisSession.padId at enqueue time — AFTER the awaits in handleMessage() (checkAccess and the handleMessageSecurity/handleMessage hooks). A concurrent same-socket CLIENT_READY can mutate sessioninfos[socket.id] in place during those awaits (the object-identity disconnect check does not catch an in-place mutation), so the queue key — and thus the write — could still be redirected onto a read-only / unauthorized pad after the read-only gate passed. Pin the pad id and read-only flag into a consistent snapshot (messagePadId / messageReadonly) together with `auth`, BEFORE any of those awaits, and use the snapshot for the read-only gate, the hook context, the queue key and the write. The gate, the queue key and the write now all refer to the same pad regardless of concurrent CLIENT_READY swaps. handleUserChanges keeps using its authorizedPadId argument (defence for the enqueue->apply window). The test now drives a real USER_CHANGES over a socket and swaps the session padId mid-message via a handleMessageSecurity hook (the concurrent-CLIENT_READY race), asserting the write lands on the authorized pad and never the victim — verified RED when the pinned key is reverted to thisSession.padId. This also drops the handleUserChanges export and the direct-call test (Qodo maintainability / pendingEdits / cleanup findings). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Good catch @qodo-free-for-open-source-projects — you're right, the first cut wasn't sufficient. The channel key was still read from Fixed properly in the latest commit: the pad id + read-only flag are now pinned into a consistent snapshot ( |
Thanks for the update. Pinning |
Thanks for the update. Pinning |
| const accept = common.waitForSocketEvent(socket, 'message'); | ||
| await common.sendUserChanges(socket, { | ||
| baseRev: rev, | ||
| changeset: 'Z:5>2*0+2$ZZ', | ||
| apool: {numToAttrib: {0: ['author', authorId]}, nextNum: 1}, | ||
| }); | ||
| await accept; // wait for the server to finish applying |
Fixes GHSA-6mcx-x5h6-rpw2 (triage, medium — runtime-proven). Verified still present on
develop:handleUserChangesre-readssessioninfos[socket.id].padIdat apply time (the write target), while the read-only gate and thepadChannelskey are taken at enqueue time.The bug (CWE-362 → CWE-862)
Per-socket messages are processed concurrently and a thrown handler does not disconnect the socket. So a same-socket
CLIENT_READYcan swap the session'spadIdbetween aUSER_CHANGESbeing authorized+enqueued and it being applied:CLIENT_READY(A)— a writable pad →padId=A.USER_CHANGES→ passes the read-only gate on A, enqueues on channel A.CLIENT_READY(B)— a read-only / unauthorized pad → swapssessioninfos[socket.id].padId=B(and a denied CLIENT_READY still leaves it mutated, without disconnecting).handleUserChangesre-readspadId=B→ writes to B.A read-only share holder can thus overwrite the pad; the read-only enforcement and per-pad authorization are bypassed.
Fix
The
padChannelschannel key already is the enqueue-timepadId. Thread it intohandleUserChangesasauthorizedPadIdand use it forgetPad(...)instead of re-reading the mutable session. The authorization gate and the write now refer to the same pad, closing the window. Non-racing flows are unchanged (session padId == enqueue-time padId).Test
crossPadWriteToctou.tsinvokeshandleUserChangeswith the session padId already swapped to a victim pad and asserts the change lands on the authorized (argument) pad and never the victim. Confirmed it fails when the vulnerable read is reintroduced (the victim gets modified). Fullmessages.tssocket suite (13) still passes;tsc --noEmitclean.🤖 Generated with Claude Code