fix(web): reject non-active machine tokens in auth middleware - #210
Conversation
The Bearer-token auth path looked up the machine token by its raw value and admitted any row returned by getMachineTokenByToken, regardless of status. Tokens created by POST /api/machine-tokens are stored with status="pending" and only transitioned to "active" by the daemon calling /api/machine-tokens/activate. This meant a pending token authenticated as the owning user for any endpoint that did not require a workspace scope (e.g. /api/me, workspace creation, listing/creating additional machine tokens). Anyone who obtained the raw token between issuance and activation — clipboard sniffer, screen capture, browser extension, etc. — could impersonate the user during that window, and indefinitely if the token was never activated. Reject tokens whose status is not exactly "active" with the same 401 "invalid token" response used for unknown tokens. Existing test mocks updated to set status="active"; new test asserts pending tokens are refused and the wrapped handler is not invoked.
|
Review — looks correct and well-scoped. One follow-up to flag. The fix is right: Follow-up (not a blocker for this PR):
Both would admit a CI: I don't see any CI checks reported on this branch yet — please make sure the pipeline runs green before merge (the change is small but the auth path is sensitive). Otherwise this is solid and ready once CI is green. (Note: I only leave review comments — not posting a GitHub approval.) |
|
Thanks for the review. Opened #225 for the follow-up: deleted both On CI: my local checks pass ( |
The POST-then-auth test in machine-token.test.ts was relying on the same bug this PR closes. It created a fresh token (status="pending" by default) and immediately used it to authenticate, expecting 200. The buggy withAuth accepted any non-null row from getMachineTokenByToken, so the assertion passed by accident. With the fix in place, pending tokens correctly get 401 and the test surfaced the bug instead of hiding it. This mirrors the unit-test happy-path fix already in this PR: instead of omitting status (which masks the gap), the test now activates the token through /api/machine-tokens/activate and then asserts the activated token authenticates as expected. Same flow a real CLI user would take. Pattern is identical to the activation tests already in this file.
|
Took a look at the E2E failure. It's the same bug class this PR closes, in a different test.
Same shape as the unit-test happy-path mocks you already noted ("masked the bug by omitting status"). The E2E version was probably harder to spot locally because it requires Fixed in Local checks green at |
|
Re-review of the follow-up changes — looks good, ready once CI is green. Verified the two changes since my last pass:
Core fix unchanged and still correct ( Only open item: GitHub Actions CI still hasn't run on this branch (fork PR needs a maintainer to approve the workflow run). The change is small but the auth path is sensitive — please kick off CI before merge. (As always, I only leave review comments, no GitHub approval.) |
…-active-machine-tokens
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Daily-review ping (logic already reviewed above): no CI checks are currently reported on this branch and the merge state shows |
…-active-machine-tokens
|
Updated the branch to current
Local verification at E2E passed with 35 files and 237 tests. CLI integration passed with 14 tests and 1 skipped. GitHub created CI run |
…l dual-actor door (route/disc trunk step 5a) Grow the id-in-path canonical route into the one door (Melly #210 corrected direction: channels/{id}/messages lives, flat verbs die). The former bot-only `send` verb folds in here; both callers served by one handler, addressed id-xor-ref: human/web puts the real channelId in the path, bot/CLI sends ref-in-body (path `[id]` = the `resolve` placeholder, since a ref carries `/`). - Door-core (new, message-door.ts): parseTargetDescriptor (id XOR ref; create-if-missing ref-arm-only = resolve≠create type invariant) + resolveMessageTarget (the single surfaceToTarget source, Blondie #28): ref → resolveTargetForMember / id → direct, BOTH through ONE requireMessageSurfaceAccess output (Aigneis ④ — the id arm never skips the mask on a "web-trusted" id), then surface→target incl the dm arm consuming the dispatch peer (no re-query). - POST: withCommunityActor + credential actor-dispatch first (ASSERT 1 ①-A); §3 (Ingaborg #219) — the standalone requireChannelMember is REPLACED by the dispatch (which subsumes the member check AND returns the channel for the bearing gate), so there is exactly ONE authorization entry (no standalone member/DM gate = no bypass of the single mask). A bot hitting this id route passes the same mask, never skipped (ASSERT 1 ①-C, cross-cell bot+{id} now a real path). DM ids fold in via the dm arm → requireDMAccess block gate (closes the block-bypass P0 by convergence, not a 400-reject). Bot arm preserves the alignment gate + CAS + seenUpToSeq + heatmap + agent-message response verbatim. NOT this commit (later steps): GET/read still withAuth (read convergence next); flat verbs + dm/threads shells still exist (parallel-grow — deleted once, after bot HTTP target + proxy rewrite move to channels/{id}, grep D/P/U incl proxy). Tests: door-core unit (14 — id-xor-ref, single-mask, surface→target, hint, resolve≠create); channels messages POST reworked to the dual-actor door (human arm; DM-id test flips from "400 reject" to "dm arm block gate → 403"). web 3589 / typecheck / lint 0 errors. against-hash on e74f9d0.
…l dual-actor door (route/disc trunk step 5a) Grow the id-in-path canonical route into the one door (Melly #210 corrected direction: channels/{id}/messages lives, flat verbs die). The former bot-only `send` verb folds in here; both callers served by one handler, addressed id-xor-ref: human/web puts the real channelId in the path, bot/CLI sends ref-in-body (path `[id]` = the `resolve` placeholder, since a ref carries `/`). - Door-core (new, message-door.ts): parseTargetDescriptor (id XOR ref; create-if-missing ref-arm-only = resolve≠create type invariant) + resolveMessageTarget (the single surfaceToTarget source, Blondie #28): ref → resolveTargetForMember / id → direct, BOTH through ONE requireMessageSurfaceAccess output (Aigneis ④ — the id arm never skips the mask on a "web-trusted" id), then surface→target incl the dm arm consuming the dispatch peer (no re-query). - POST: withCommunityActor + credential actor-dispatch first (ASSERT 1 ①-A); §3 (Ingaborg #219) — the standalone requireChannelMember is REPLACED by the dispatch (which subsumes the member check AND returns the channel for the bearing gate), so there is exactly ONE authorization entry (no standalone member/DM gate = no bypass of the single mask). A bot hitting this id route passes the same mask, never skipped (ASSERT 1 ①-C, cross-cell bot+{id} now a real path). DM ids fold in via the dm arm → requireDMAccess block gate (closes the block-bypass P0 by convergence, not a 400-reject). Bot arm preserves the alignment gate + CAS + seenUpToSeq + heatmap + agent-message response verbatim. NOT this commit (later steps): GET/read still withAuth (read convergence next); flat verbs + dm/threads shells still exist (parallel-grow — deleted once, after bot HTTP target + proxy rewrite move to channels/{id}, grep D/P/U incl proxy). Tests: door-core unit (14 — id-xor-ref, single-mask, surface→target, hint, resolve≠create); channels messages POST reworked to the dual-actor door (human arm; DM-id test flips from "400 reject" to "dm arm block gate → 403"). web 3589 / typecheck / lint 0 errors. against-hash on e74f9d0.
…l dual-actor door (route/disc trunk step 5a) Grow the id-in-path canonical route into the one door (Melly #210 corrected direction: channels/{id}/messages lives, flat verbs die). The former bot-only `send` verb folds in here; both callers served by one handler, addressed id-xor-ref: human/web puts the real channelId in the path, bot/CLI sends ref-in-body (path `[id]` = the `resolve` placeholder, since a ref carries `/`). - Door-core (new, message-door.ts): parseTargetDescriptor (id XOR ref; create-if-missing ref-arm-only = resolve≠create type invariant) + resolveMessageTarget (the single surfaceToTarget source, Blondie #28): ref → resolveTargetForMember / id → direct, BOTH through ONE requireMessageSurfaceAccess output (Aigneis ④ — the id arm never skips the mask on a "web-trusted" id), then surface→target incl the dm arm consuming the dispatch peer (no re-query). - POST: withCommunityActor + credential actor-dispatch first (ASSERT 1 ①-A); §3 (Ingaborg #219) — the standalone requireChannelMember is REPLACED by the dispatch (which subsumes the member check AND returns the channel for the bearing gate), so there is exactly ONE authorization entry (no standalone member/DM gate = no bypass of the single mask). A bot hitting this id route passes the same mask, never skipped (ASSERT 1 ①-C, cross-cell bot+{id} now a real path). DM ids fold in via the dm arm → requireDMAccess block gate (closes the block-bypass P0 by convergence, not a 400-reject). Bot arm preserves the alignment gate + CAS + seenUpToSeq + heatmap + agent-message response verbatim. NOT this commit (later steps): GET/read still withAuth (read convergence next); flat verbs + dm/threads shells still exist (parallel-grow — deleted once, after bot HTTP target + proxy rewrite move to channels/{id}, grep D/P/U incl proxy). Tests: door-core unit (14 — id-xor-ref, single-mask, surface→target, hint, resolve≠create); channels messages POST reworked to the dual-actor door (human arm; DM-id test flips from "400 reject" to "dm arm block gate → 403"). web 3589 / typecheck / lint 0 errors. against-hash on e74f9d0.
…l dual-actor door (route/disc trunk step 5a) Grow the id-in-path canonical route into the one door (Melly #210 corrected direction: channels/{id}/messages lives, flat verbs die). The former bot-only `send` verb folds in here; both callers served by one handler, addressed id-xor-ref: human/web puts the real channelId in the path, bot/CLI sends ref-in-body (path `[id]` = the `resolve` placeholder, since a ref carries `/`). - Door-core (new, message-door.ts): parseTargetDescriptor (id XOR ref; create-if-missing ref-arm-only = resolve≠create type invariant) + resolveMessageTarget (the single surfaceToTarget source, Blondie #28): ref → resolveTargetForMember / id → direct, BOTH through ONE requireMessageSurfaceAccess output (Aigneis ④ — the id arm never skips the mask on a "web-trusted" id), then surface→target incl the dm arm consuming the dispatch peer (no re-query). - POST: withCommunityActor + credential actor-dispatch first (ASSERT 1 ①-A); §3 (Ingaborg #219) — the standalone requireChannelMember is REPLACED by the dispatch (which subsumes the member check AND returns the channel for the bearing gate), so there is exactly ONE authorization entry (no standalone member/DM gate = no bypass of the single mask). A bot hitting this id route passes the same mask, never skipped (ASSERT 1 ①-C, cross-cell bot+{id} now a real path). DM ids fold in via the dm arm → requireDMAccess block gate (closes the block-bypass P0 by convergence, not a 400-reject). Bot arm preserves the alignment gate + CAS + seenUpToSeq + heatmap + agent-message response verbatim. NOT this commit (later steps): GET/read still withAuth (read convergence next); flat verbs + dm/threads shells still exist (parallel-grow — deleted once, after bot HTTP target + proxy rewrite move to channels/{id}, grep D/P/U incl proxy). Tests: door-core unit (14 — id-xor-ref, single-mask, surface→target, hint, resolve≠create); channels messages POST reworked to the dual-actor door (human arm; DM-id test flips from "400 reject" to "dm arm block gate → 403"). web 3589 / typecheck / lint 0 errors. against-hash on e74f9d0.
fix(web): reject non-active machine tokens in auth middleware
Problem
The Bearer-token branch of withAuth admitted any row returned by getMachineTokenByToken, regardless of status. Pending tokens authenticated as the owning user.
Tokens created by POST /api/machine-tokens are stored with status="pending" and only transition to "active" when the daemon calls POST /api/machine-tokens/activate with a hostname and runtimes. Until that activation step, the raw token exists in the DB and is returned to the user (so the UI can show it / pipe it to the daemon).
Because auth.ts did not check mt.status, anyone holding the raw pending token — clipboard sniffer, screen capture, browser extension that scrapes the page, malicious helper running on the same machine — could call any authenticated route as that user. Endpoints that do not require workspaceId (e.g. /api/me, workspace creation, listing/creating additional machine tokens) work without activation. If a token was created and never activated, the window stays open indefinitely; once cached, the KV cache (TTL 900s) keeps the pending state hot.
After activation the token is correctly invalidated in the cache, so legitimate daemons keep working — this change only closes the pre-activation gap.
Fix
Tests
Run: pnpm --filter @alook/web test (1027 passing, +1 vs main).
Verification
Scope
Single-file behavioural change plus test. No schema, query, or API contract changes. No new dependencies.