From f91b214719265e784328ac1b881bbbfdbcae2a7a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:45:11 +0200 Subject: [PATCH] fix: key synced settings on the account a token login resolves to, not on a bare claimed name --- app/api/auth/session/route.ts | 12 +++++-- app/api/settings/route.ts | 19 +++++++---- .../auth-routes-credential-check.test.ts | 8 +++++ .../settings-route-account-key.test.ts | 34 +++++++++++++++++++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 1c4317d4e..3b0bbb5dd 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -4,7 +4,7 @@ import { logger } from '@/lib/logger'; import { encryptSession, decryptSession, sessionAuthHeader, type SessionCredentials } from '@/lib/auth/crypto'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; -import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth'; +import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth'; import { clearStalwartAuthContextInStore, setStalwartAuthContextInStore, @@ -144,12 +144,17 @@ export async function POST(request: NextRequest) { { status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } }, ); } + // The context cookie also records which account the credential belongs + // to: a Bearer login may claim a bare local part (`john`) that several + // accounts on a multi-domain server answer to, and per-user data such as + // synced settings must be keyed on the account, not on the claim. let normalizedServerUrl: string; + let accountName: string; try { - normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, { + ({ serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, { trusted: upstreamTrusted, clientIp, - }); + })); } catch (error) { // Only a credential the server rejected spends the budget; an outage // or a mismatched claim says nothing about the password. @@ -164,6 +169,7 @@ export async function POST(request: NextRequest) { serverUrl: normalizedServerUrl, username, authHeader, + ...(accountName !== username ? { accountName } : {}), }); void recordLogin(username, normalizedServerUrl); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 8b198bf5a..a3ed0f676 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -79,20 +79,25 @@ async function verifyIdentity(username: string, serverUrl: string): Promise { expect.anything(), 0, expect.objectContaining({ username: 'alice@example.org', serverUrl: SERVER }), ); }); + + it('records the account a bare Bearer claim resolves to, so per-user data is not keyed on the claim', async () => { + const { status } = await postSession({ serverUrl: SERVER, username: 'mallory', token: 'tok-mallory', slot: 0 }); + expect(status).toBe(200); + expect(setStalwartAuthContextInStore).toHaveBeenCalledWith( + expect.anything(), 0, expect.objectContaining({ username: 'mallory', accountName: 'mallory@example.org' }), + ); + }); }); describe('POST /api/auth/stalwart-context (GHSA-wxcm-j4jc-9fxq)', () => { diff --git a/lib/__tests__/settings-route-account-key.test.ts b/lib/__tests__/settings-route-account-key.test.ts index 3b14ed99e..f73fe0aed 100644 --- a/lib/__tests__/settings-route-account-key.test.ts +++ b/lib/__tests__/settings-route-account-key.test.ts @@ -84,6 +84,40 @@ describe('settings route keys on the account the credential belongs to', () => { expect(await res.json()).toEqual({ settings: null }); }); + it('keys on the canonical account even when a remember-me session cookie carries the bare claim', async () => { + // /api/auth/session writes both cookies for a token login; the session + // cookie only knows the claim, the context the account it resolved to. + store.set(`john|${SERVER}`, { signature: 'john@a.example private' }); + await signIn({ username: 'john', accountName: 'john@b.example' }); + const { encryptSession } = await import('@/lib/auth/crypto'); + const { sessionCookieName } = await import('@/lib/auth/session-cookie'); + jar.set(sessionCookieName(0), encryptSession(SERVER, 'john', { token: 't' })); + const { GET, POST } = await import('@/app/api/settings/route'); + const read = await GET(new NextRequest('https://webmail.example/api/settings', { + headers: { 'x-settings-username': 'john', 'x-settings-server': SERVER }, + })); + expect(await read.json()).toEqual({ settings: null }); + await POST(new NextRequest('https://webmail.example/api/settings', { + method: 'POST', + headers: SAME_ORIGIN, + body: JSON.stringify({ username: 'john', serverUrl: SERVER, settings: { theme: 'dark' } }), + })); + expect(store.get(`john|${SERVER}`)).toEqual({ signature: 'john@a.example private' }); + expect(store.get(`john@b.example|${SERVER}`)).toEqual({ theme: 'dark' }); + }); + + it('refuses to key on a bare token-login claim when no context cookie resolves it', async () => { + jar.clear(); + const { encryptSession } = await import('@/lib/auth/crypto'); + const { sessionCookieName } = await import('@/lib/auth/session-cookie'); + jar.set(sessionCookieName(0), encryptSession(SERVER, 'john', { token: 't' })); + const { GET } = await import('@/app/api/settings/route'); + const res = await GET(new NextRequest('https://webmail.example/api/settings', { + headers: { 'x-settings-username': 'john', 'x-settings-server': SERVER }, + })); + expect(res.status).toBe(403); + }); + it('keeps keying on the login name when the context names no other account', async () => { await signIn({ username: 'alice@example.org' }); const { POST } = await import('@/app/api/settings/route');