8000 fix(console-api): require a passkey session to mint API tokens (CVE-2026-97406) by xerj-org · Pull Request #2003 · xerj-org/xerj · GitHub
[go: up one dir, main page]

Skip to content

fix(console-api): require a passkey session to mint API tokens (CVE-2026-97406) - #2003

Merged
xerj-org merged 1 commit into
mainfrom
cursor/console-api-passkey-token-gate-5a23
Oct 21, 2026
Merged

xerj-org merged 1 commit into
mainfrom
cursor/console-api-passkey-token-gate-5a23

Conversation

@xerj-org
< 8000 summary data-dd-action-name="pr.description.open_menu" data-view-component="true" class="timeline-comment-action Link--secondary Button--link Button--medium Button"> Copy link
Copy Markdown
Owner

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, a xerj_session cookie and Authorization: Bearer <api token> both resolved to the same AuthSession (auth/sessions.rs, authenticate_bearer). POST /_xerj-console/api/v1/auth/api-tokens accepted any AuthSession.

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.

PasskeySession is that passkey-backed browser session: a cookie whose .xerj_sessions row has idp: "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_id was added. tokens::create is the only insert of a new API token row, and it takes PasskeySession, so a bearer token cannot mint a descendant for revocation to miss. store::revoke_api_tokens_for_user stays 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 token
  • DELETE /_xerj-console/api/v1/auth/api-tokens/:id — revoke an API token
  • POST /_xerj-console/api/v1/auth/magic/issue — issue an invitation
  • DELETE /_xerj-console/api/v1/auth/passkeys/:id — delete a passkey
  • POST /_xerj-console/api/v1/auth/logout — revoke the current browser session

Still 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 AuthSession route

  • POST /auth/magic/redeem stays unauthenticated. The magic link is the credential, and the invitee has no session yet. The stolen-token path into redeem was magic/issue.
  • POST /auth/passkey/begin and /finish trust the enrollment session from redeem. There is no "enroll a passkey while holding an API token" handler.
  • POST /auth/login/begin and /finish are the WebAuthn ceremony.

No /users handler and no "revoke some other session" handler exist. A future SSO cookie (idp other than passkey) fails PasskeySession as well.

Evidence

Toolchain: rustc 1.97.1 (8bab26f4f 2026-07-14).

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 build --release -j 32 -p xerj-console-api
# Finished `release` profile [optimized] target(s) in 3m 00s, exit 0

cargo +1.97.1 test -p xerj-console-api -- --test-threads=4 --skip racing_redemptions
# lib 65 passed, 2 filtered; api_tokens_flow 8 passed; plus the other
# console-api integration binaries (159 passed, 2 skipped, 1 doc-test ignored)

api_tokens_flow now 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, and PUT /prefs still succeed with the bearer token; an unknown bearer on mint is 401; a non-passkey (idp: oidc) cookie is 403 on mint and still authenticates GET /me.

The two skipped tests, racing_redemptions_of_distinct_tokens_all_succeed and its sibling, abort with a tokio worker stack overflow in this environment. The same overflow happens on unmodified main (verified by stashing this change and rerunning racing_redemptions_of_distinct). They do not call the handlers this PR gates.

Checks

  • cargo fmt --all (CI runs rustfmt --check and clippy -D warnings)
  • Scoped release build of the crates touched: cargo build --release -j 32 -p xerj-console-api
  • cargo test -p xerj-console-api passes, except two pre-existing racing tests that stack-overflow on main in this environment (see Evidence)
  • ES-YAML conformance suite at 0 failed — not applicable: this change is the console session extractor, not the ES wire protocol
  • New ES-compatible behaviour has a matching YAML case — not applicable, same reason
  • Docs updated: docs/SECURITY_MODEL.md, engine/docs/xerj-console-backend/API_DESIGN_v1.md §3.7, CHANGELOG.md, and the token bullet in demo/DEMO_RUNBOOK.md
  • Contribution review: independent branch off main. No schema change. The 403 is in the extractor, before put_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_bearer and tokens::create. The new tests are the regression.

Provenance

  • Written by: AI agent (Cursor Grok 4.7), on the account that opens this pull request
  • Verified (commands run, output observed): 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_succeed stack-overflows on this branch and on stashed main.
  • Assumed (not tested, and what would falsify it): that a bearer token could mint before this change. Falsified if a pre-fix build already returned 403 from POST /auth/api-tokens with Authorization: Bearer. Also assumed: no other crate inserts a new .xerj_api_tokens secret. Falsified if a caller other than tokens::create reaches put_api_token with a fresh secret.
Open in Web Open in Cursor 

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0