Repository navigation
fix(console-api): require a passkey session to mint API tokens (CVE-2026-97406) - #2003
Merged
Merged
Conversation
…026-97406) Cookie sessions and Authorization: Bearer both resolved to AuthSession, and POST /auth/api-tokens accepted either. A holder of one API token could mint independent successors that survived revocation of the parent, and an owner token could issue an invitation that enrolls an attacker's passkey (CVE-2026-97406, CERT VU#645818). PasskeySession is the passkey-backed browser session. Creating and revoking API tokens, issuing invitations, deleting passkeys, and logout extract it and return 403 for a bearer token before any write. Ordinary console calls still accept bearer tokens. No parent_id is stored: tokens::create is the only insert of a new token row, and it cannot be reached from a token, so revocation has no descendants to cascade. Tests in api_tokens_flow cover bearer 403 on the gated routes, a passkey cookie still minting, and bearer reads and writes of ordinary console data. Co-authored-by: xerj-org <xerj-org@users.noreply.github.com>
xerj-org
marked this pull request as ready for review
October 20, 2026 19:44
This was referenced Oct 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes, and why
CVE-2026-97406 / CERT VU#645818 (published 2026-10-20). The console security contract says issuing an API token requires a passkey-backed session. In
xerj-console-api, axerj_sessioncookie andAuthorization: Bearer <api token>both resolved to the sameAuthSession(auth/sessions.rs,authenticate_bearer).POST /_xerj-console/api/v1/auth/api-tokensaccepted anyAuthSession.Holding one API token was enough to mint independent successors. Revoking the parent left the children working, because nothing recorded a parent. With an owner token the same caller could
POST /auth/magic/issue, redeem the invitation in their own browser, and enroll their own passkey.PasskeySessionis that passkey-backed browser session: a cookie whose.xerj_sessionsrow hasidp: "passkey". The extractor returns 401 when nothing authenticates, and 403 with a stable message when the caller is a valid API token or a non-passkey browser session. The check runs before the handler, so a refusal writes nothing. When a cookie and a bearer header are both present, the bearer wins and the request is not upgraded onto the cookie.No
parent_idwas added.tokens::createis the only insert of a new API token row, and it takesPasskeySession, so a bearer token cannot mint a descendant for revocation to miss.store::revoke_api_tokens_for_userstays unwired; deleting the last passkey is still refused with 409.Endpoints that now require a passkey session (bearer → 403)
POST /_xerj-console/api/v1/auth/api-tokens— mint an API tokenDELETE /_xerj-console/api/v1/auth/api-tokens/:id— revoke an API tokenPOST /_xerj-console/api/v1/auth/magic/issue— issue an invitationDELETE /_xerj-console/api/v1/auth/passkeys/:id— delete a passkeyPOST /_xerj-console/api/v1/auth/logout— revoke the current browser sessionStill accept a bearer token
GET /me,GET /auth/passkeys,GET /auth/api-tokens(metadata only), prefs, dashboards (including_bulk), saved views, data-source reads,GET /knowledge, and the/graph/*reads.Not an
AuthSessionroutePOST /auth/magic/redeemstays unauthenticated. The magic link is the credential, and the invitee has no session yet. The stolen-token path into redeem wasmagic/issue.POST /auth/passkey/beginand/finishtrust the enrollment session from redeem. There is no "enroll a passkey while holding an API token" handler.POST /auth/login/beginand/finishare the WebAuthn ceremony.No
/usershandler and no "revoke some other session" handler exist. A future SSO cookie (idpother thanpasskey) failsPasskeySessionas well.Evidence
Toolchain: rustc 1.97.1 (8bab26f4f 2026-07-14).
api_tokens_flownow proves: a passkey cookie still mints and revokes; a bearer token is 403 on mint (alone and together with the cookie) and no second token row is stored; the same 403 on revoke, invite issue, passkey delete, and logout, with the rows and the browser session left in place;GET /me, token list, passkey list, andPUT /prefsstill succeed with the bearer token; an unknown bearer on mint is 401; a non-passkey (idp: oidc) cookie is 403 on mint and still authenticatesGET /me.The two skipped tests,
racing_redemptions_of_distinct_tokens_all_succeedand its sibling, abort with a tokio worker stack overflow in this environment. The same overflow happens on unmodifiedmain(verified by stashing this change and rerunningracing_redemptions_of_distinct). They do not call the handlers this PR gates.Checks
cargo fmt --all(CI runsrustfmt --checkandclippy -D warnings)cargo build --release -j 32 -p xerj-console-apicargo test -p xerj-console-apipasses, except two pre-existing racing tests that stack-overflow onmainin this environment (see Evidence)docs/SECURITY_MODEL.md,engine/docs/xerj-console-backend/API_DESIGN_v1.md§3.7,CHANGELOG.md, and the token bullet indemo/DEMO_RUNBOOK.mdmain. No schema change. The 403 is in the extractor, beforeput_api_token/put_magic_link/delete_passkey/revoke_session; the new tests assert those rows are unchanged. Bearer auth for ordinary console data is preserved. The runbook edit is a one-bullet contract correction, not a new procedure, so the operational-recipe audit is N/A.Not run: ES-YAML. The pre-fix bearer mint was not executed as a failing test; the 200-before behaviour is from reading
authenticate_bearerandtokens::create. The new tests are the regression.Provenance
cargo +1.97.1 fmt --all --check(exit 0);cargo +1.97.1 clippy -p xerj-console-api --all-targets -- -D warnings(exit 0);cargo +1.97.1 test -p xerj-console-api -- --test-threads=4 --skip racing_redemptions(159 passed, 2 skipped, 1 doc-test ignored);cargo +1.97.1 build --release -j 32 -p xerj-console-api(finished in 3m 00s, exit 0);racing_redemptions_of_distinct_tokens_all_succeedstack-overflows on this branch and on stashedmain.POST /auth/api-tokenswithAuthorization: Bearer. Also assumed: no other crate inserts a new.xerj_api_tokenssecret. Falsified if a caller other thantokens::createreachesput_api_tokenwith a fresh secret.