From 006676a59b4f9a9abcd281979d3ebb0757bb8a40 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 16:49:00 -0800 Subject: [PATCH 1/2] fix(cloud): ORG-TOKEN-STALE Worker backstop: x-orca-org mismatch -> 409 org_mismatch; role-changed token -> 401 stale_token on create, secrets and GitHub --- cloud/apps/sandbox-control/src/auth.ts | 34 ++++++++- cloud/apps/sandbox-control/src/index.ts | 46 +++++++++++- .../test/org-token-stale.test.ts | 72 +++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 cloud/apps/sandbox-control/test/org-token-stale.test.ts diff --git a/cloud/apps/sandbox-control/src/auth.ts b/cloud/apps/sandbox-control/src/auth.ts index 3d4b3b24510..a0fed6fa3ae 100644 --- a/cloud/apps/sandbox-control/src/auth.ts +++ b/cloud/apps/sandbox-control/src/auth.ts @@ -70,6 +70,28 @@ export function readBearer(value: string | null): string | null { return /^Bearer ([^\s]+)$/.exec(value ?? '')?.[1] ?? null } +/** + * ORG-TOKEN-STALE: the desktop says which team it is acting for (`x-orca-org: `, or `user` for a + * personal account). A token minted for another team (stale after a team or role switch) is refused + * rather than acting on the wrong team. No header: no check (older desktops). + */ +export const ORG_HEADER = 'x-orca-org' +export class OrgMismatchError extends Error { + constructor() { + super('org_mismatch') + this.name = 'OrgMismatchError' + } +} +export function assertExpectedOrg(request: Request, tokenOrgId: string | undefined): void { + const expected = request.headers.get(ORG_HEADER)?.trim() + if (!expected) { + return + } + if (expected !== (tokenOrgId ?? 'user')) { + throw new OrgMismatchError() + } +} + /** Same account rule as billing: an org pays for its members, otherwise the user is the account. */ export function accountIdFor(claims: { sub: string; org?: string }): string { return claims.org ? `org:${claims.org}` : `user:${claims.sub}` @@ -144,7 +166,11 @@ export async function authenticateDesktop( (await verify(token, keysFor(env), env, env.SANDBOX_AUTH_ISSUER, now)) ?? (staging ? await verify(token, staging.keys, env, staging.issuer, now) : null) if (caller || !env.SANDBOX_DEV_JWKS || env.SANDBOX_ENV === 'prod') { - return caller && (await tokenPredatesRemoval(env.SANDBOX_DB, caller)) ? null : caller + if (!caller || (await tokenPredatesRemoval(env.SANDBOX_DB, caller))) { + return null + } + assertExpectedOrg(request, caller.orgId) + return caller } if (devKeys?.source !== env.SANDBOX_DEV_JWKS) { devKeys = { @@ -159,5 +185,9 @@ export async function authenticateDesktop( } // Org-less dev tokens keep their historical `dev:` account; org dev tokens get `dev:org:`. const devCaller: SandboxCaller = { ...dev, accountId: dev.orgId ? `dev:org:${dev.orgId}` : `dev:${dev.userId}`, dev: true } - return (await tokenPredatesRemoval(env.SANDBOX_DB, devCaller)) ? null : devCaller + if (await tokenPredatesRemoval(env.SANDBOX_DB, devCaller)) { + return null + } + assertExpectedOrg(request, devCaller.orgId) + return devCaller } diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index a107b9c091d..09720b2ea8a 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -47,8 +47,8 @@ import { WorkerEntrypoint } from 'cloudflare:workers' import type { RunnerBuild } from './build-runner' import { maintainPool, poolConfigFromEnv } from './pool' import { cachedCreateTiming } from './create-timing' -import { creatorLeftAccount } from './member-changes' -import { authenticateDesktop } from './auth' +import { creatorLeftAccount, tokenPredatesMemberChange } from './member-changes' +import { authenticateDesktop, OrgMismatchError } from './auth' import { authenticateApi } from './api/caller' import { handleApi, handleApiAs, isApiPath, type ApiContext, type ApiExtension } from './api/router' import { handleMcpRoutes, isMcpPath, oauthApprovalsApi, oauthGrantsApi } from './mcp' @@ -1160,6 +1160,29 @@ async function shareGateway(request: Request, url: URL, env: Env, deps: Deps): P } export async function handleRequest(request: Request, env: Env, deps: Deps = {}): Promise { + try { + return await routeRequest(request, env, deps) + } catch (error) { + // A stale-org token anywhere in routing (ORG-TOKEN-STALE): the desktop re-mints once and retries. + if (error instanceof OrgMismatchError) { + return orgMismatchResponse() + } + throw error + } +} + +function orgMismatchResponse(): Response { + return json( + { + error: 'org_mismatch', + reason: 'org_mismatch', + message: 'Orca Cloud signed you in for a different team. Try again.' + }, + 409 + ) +} + +async function routeRequest(request: Request, env: Env, deps: Deps): Promise { const url = new URL(request.url) try { const shared = await shareGateway(request, url, env, deps) @@ -1394,6 +1417,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) if (caller.deviceSessionId) { return fail('forbidden', 403, 'device_session') } + if (await tokenPredatesMemberChange(env.SANDBOX_DB, caller)) { + // ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries. + return fail('unauthorized', 401, 'stale_token') + } if (request.method !== 'GET') { return fail('not_found', 404) } @@ -1419,6 +1446,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) if (caller.deviceSessionId) { return fail('forbidden', 403, 'device_session') } + if (await tokenPredatesMemberChange(env.SANDBOX_DB, caller)) { + // ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries. + return fail('unauthorized', 401, 'stale_token') + } if (url.pathname === '/v1/github/installations' && request.method === 'GET') { const rows = await listAccountInstallations(env.SANDBOX_DB, caller.accountId) return json({ @@ -1493,6 +1524,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) if (caller.deviceSessionId) { return fail('forbidden', 403, 'device_session') } + if (await tokenPredatesMemberChange(env.SANDBOX_DB, caller)) { + // ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries. + return fail('unauthorized', 401, 'stale_token') + } const secrets = await secretsDeps(env) if (!secrets) { return fail('not_configured', 503) @@ -1639,6 +1674,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) if (request.method !== 'POST') { return fail('not_found', 404) } + if (await tokenPredatesMemberChange(life.db, caller)) { + // ORG-TOKEN-STALE: never create (and bill) with a token minted before a role change. + return fail('unauthorized', 401, 'stale_token') + } await consumeCreateToken(life.db, life.config.guardrails!, caller.accountId, life.now()) const raw = await readJson(request) const body = CreateRequestSchema.safeParse(raw) @@ -2257,6 +2296,9 @@ function errorResponse(error: unknown): Response { if (error instanceof SecretError) { return fail(error.code, error.status, error.reason) } + if (error instanceof OrgMismatchError) { + return orgMismatchResponse() + } if (error instanceof GuardrailError) { if (error.code === 'concurrency_limit') { return fail('entitlement_denied', 403, 'concurrency_limit') diff --git a/cloud/apps/sandbox-control/test/org-token-stale.test.ts b/cloud/apps/sandbox-control/test/org-token-stale.test.ts new file mode 100644 index 00000000000..ae17a7b925a --- /dev/null +++ b/cloud/apps/sandbox-control/test/org-token-stale.test.ts @@ -0,0 +1,72 @@ +// ORG-TOKEN-STALE (Worker backstop): the desktop names the team it is acting for (`x-orca-org`), and a token +// minted for another team is refused with 409 org_mismatch instead of acting on the wrong team. A token minted +// before a role change is refused (401 stale_token) where it would create, read secrets, or reach GitHub. +import { describe, expect, it } from 'vitest' +import { recordMemberChange } from '../src/member-changes' +import { createHarness } from './harness' + +async function setup() { + const h = await createHarness({ SECRETS_KEK: Buffer.alloc(32, 7).toString('base64') }) + const teamToken = await h.token('alice', 'acme', 'sandbox', { org_role: 'admin' }) + const personalToken = await h.token('alice') + return { h, teamToken, personalToken } +} + +describe('x-orca-org: a token for another team is refused', () => { + it('no header: no check (older desktops)', async () => { + const { h, teamToken } = await setup() + const res = await h.call('GET', '/v1/sandboxes', { bearer: teamToken }) + expect(res.status).toBe(200) + }) + + it('header matching the token’s org passes', async () => { + const { h, teamToken } = await setup() + const res = await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'acme' } }) + expect(res.status).toBe(200) + }) + + it('header naming another team: 409 org_mismatch, nothing acted on', async () => { + const { h, teamToken } = await setup() + const list = await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'other-team' } }) + expect(list.status).toBe(409) + expect(list.body).toMatchObject({ error: 'org_mismatch', reason: 'org_mismatch' }) + const create = await h.call('POST', '/v1/sandboxes', { + bearer: teamToken, + headers: { 'x-orca-org': 'other-team' }, + body: { workspaceId: 'ws-1', machineSize: 'standard' } + }) + expect(create.status).toBe(409) + const after = await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'acme' } }) + expect(after.body.sandboxes).toHaveLength(0) + }) + + it('`user` (personal) with a team token, or a team header with a personal token: 409', async () => { + const { h, teamToken, personalToken } = await setup() + expect((await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'user' } })).status).toBe(409) + expect((await h.call('GET', '/v1/sandboxes', { bearer: personalToken, headers: { 'x-orca-org': 'acme' } })).status).toBe(409) + expect((await h.call('GET', '/v1/sandboxes', { bearer: personalToken, headers: { 'x-orca-org': 'user' } })).status).toBe(200) + }) + + it('secrets and GitHub routes are covered too', async () => { + const { h, teamToken } = await setup() + const headers = { 'x-orca-org': 'other-team' } + expect((await h.call('GET', '/v1/secrets', { bearer: teamToken, headers })).status).toBe(409) + expect((await h.call('GET', '/v1/github/repositories', { bearer: teamToken, headers })).status).toBe(409) + }) +}) + +describe('a token minted before a role change: 401 stale_token where it matters', () => { + it('create, secrets and GitHub refuse it; a fresh token works', async () => { + const { h, teamToken } = await setup() + h.advance(2_000) + await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'alice', 'role', h.now()) + const create = await h.call('POST', '/v1/sandboxes', { bearer: teamToken, body: { workspaceId: 'ws-2', machineSize: 'standard' } }) + expect(create.status).toBe(401) + expect(create.body).toMatchObject({ reason: 'stale_token' }) + expect((await h.call('GET', '/v1/secrets', { bearer: teamToken })).body).toMatchObject({ reason: 'stale_token' }) + expect((await h.call('GET', '/v1/github/repositories', { bearer: teamToken })).body).toMatchObject({ reason: 'stale_token' }) + h.advance(2_000) + const fresh = await h.token('alice', 'acme', 'sandbox', { org_role: 'member' }) + expect((await h.call('GET', '/v1/secrets', { bearer: fresh })).body.reason).not.toBe('stale_token') + }) +}) From 34d86a539a0803ef2eaceb60524c42db3082b35a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 16:50:53 -0800 Subject: [PATCH 2/2] fix(cloud): stale_token on create/secrets/GitHub allows for auth's 30 s iat backdate, so the desktop's one re-mint succeeds --- cloud/apps/sandbox-control/src/index.ts | 10 +++++----- cloud/apps/sandbox-control/src/member-changes.ts | 14 ++++++++++++++ .../sandbox-control/test/org-token-stale.test.ts | 15 +++++++++++++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 09720b2ea8a..869334bf4a6 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -47,7 +47,7 @@ import { WorkerEntrypoint } from 'cloudflare:workers' import type { RunnerBuild } from './build-runner' import { maintainPool, poolConfigFromEnv } from './pool' import { cachedCreateTiming } from './create-timing' -import { creatorLeftAccount, tokenPredatesMemberChange } from './member-changes' +import { creatorLeftAccount, tokenMintedBeforeMemberChange } from './member-changes' import { authenticateDesktop, OrgMismatchError } from './auth' import { authenticateApi } from './api/caller' import { handleApi, handleApiAs, isApiPath, type ApiContext, type ApiExtension } from './api/router' @@ -1417,7 +1417,7 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise { + if (caller.tokenIssuedAt === undefined) { + return false + } + return tokenPredatesMemberChange(db, { ...caller, tokenIssuedAt: caller.tokenIssuedAt + AUTH_IAT_BACKDATE_MS }) +} + export async function tokenPredatesMemberChange(db: D1Database, caller: SandboxCaller): Promise { if (caller.tokenIssuedAt === undefined || caller.deviceSessionId || !caller.orgId) { return false diff --git a/cloud/apps/sandbox-control/test/org-token-stale.test.ts b/cloud/apps/sandbox-control/test/org-token-stale.test.ts index ae17a7b925a..3ddfdcb4bab 100644 --- a/cloud/apps/sandbox-control/test/org-token-stale.test.ts +++ b/cloud/apps/sandbox-control/test/org-token-stale.test.ts @@ -2,7 +2,7 @@ // minted for another team is refused with 409 org_mismatch instead of acting on the wrong team. A token minted // before a role change is refused (401 stale_token) where it would create, read secrets, or reach GitHub. import { describe, expect, it } from 'vitest' -import { recordMemberChange } from '../src/member-changes' +import { AUTH_IAT_BACKDATE_MS, recordMemberChange } from '../src/member-changes' import { createHarness } from './harness' async function setup() { @@ -58,7 +58,8 @@ describe('x-orca-org: a token for another team is refused', () => { describe('a token minted before a role change: 401 stale_token where it matters', () => { it('create, secrets and GitHub refuse it; a fresh token works', async () => { const { h, teamToken } = await setup() - h.advance(2_000) + // Minted well before the change, even allowing for auth's 30 s iat backdate. + h.advance(AUTH_IAT_BACKDATE_MS + 2_000) await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'alice', 'role', h.now()) const create = await h.call('POST', '/v1/sandboxes', { bearer: teamToken, body: { workspaceId: 'ws-2', machineSize: 'standard' } }) expect(create.status).toBe(401) @@ -69,4 +70,14 @@ describe('a token minted before a role change: 401 stale_token where it matters' const fresh = await h.token('alice', 'acme', 'sandbox', { org_role: 'member' }) expect((await h.call('GET', '/v1/secrets', { bearer: fresh })).body.reason).not.toBe('stale_token') }) + + it('a token re-minted just after the change (iat backdated 30 s by auth) is fresh, so the retry works', async () => { + const { h } = await setup() + // Auth stamps iat = mint time - 30 s: a token minted 5 s after the change carries iat 25 s before it. + const reminted = await h.token('alice', 'acme', 'sandbox', { org_role: 'member' }) + h.advance(25_000) + await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'alice', 'role', h.now()) + const res = await h.call('GET', '/v1/secrets', { bearer: reminted }) + expect(res.body?.reason).not.toBe('stale_token') + }) })