From e055ee8201c3ae548a8750dd3f746237d99a72f3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:46:43 +0200 Subject: [PATCH] fix: verify credentials upstream before minting identity cookies (GHSA-wxcm-j4jc-9fxq) --- app/api/auth/session/route.ts | 24 +- app/api/auth/stalwart-context/route.ts | 29 +- .../auth-routes-credential-check.test.ts | 308 ++++++++++++++++++ lib/__tests__/auth-routes-same-origin.test.ts | 16 +- lib/__tests__/verify-jmap-identity.test.ts | 181 ++++++++++ lib/auth/verify-jmap-auth.ts | 203 +++++++++++- 6 files changed, 727 insertions(+), 34 deletions(-) create mode 100644 lib/__tests__/auth-routes-credential-check.test.ts create mode 100644 lib/__tests__/verify-jmap-identity.test.ts diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 3dc3d4ae1..a7ce3aeee 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -4,12 +4,7 @@ import { logger } from '@/lib/logger'; import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; -import { - JmapAuthVerificationError, - normalizeJmapServerUrl, - validateProxyAuthHeader, - verifyJmapAuth, -} from '@/lib/auth/verify-jmap-auth'; +import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth'; import { clearStalwartAuthContextInStore, setStalwartAuthContextInStore, @@ -86,13 +81,16 @@ export async function POST(request: NextRequest) { const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request); const cookieName = sessionCookieName(slot); const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - // Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie - // we write here is only ever consumed for requests on behalf of this same - // user, so bogus credentials would just yield 401s downstream rather than - // privilege escalation. Untrusted custom endpoints still verify upstream. - const normalizedServerUrl = upstreamTrusted - ? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl)) - : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); + // Always verify the credential upstream (GHSA-wxcm-j4jc-9fxq). The cookies + // written here are not only replayed as credentials (where a bogus + // password would just 401 downstream): the encrypted auth context is also + // accepted as proof of identity by routes that never contact the mail + // server, such as settings sync. Skipping the check for admin-configured + // servers let anyone mint a cookie for any username with a made-up + // password. `trusted` only relaxes the public-address requirement. + const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, { + trusted: upstreamTrusted, + }); const token = encryptSession(normalizedServerUrl, username, password); const cookieStore = await cookies(); cookieStore.set(cookieName, token, sessionCookieOptions()); diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 80841a350..6b0801f1f 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; -import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; @@ -62,22 +62,17 @@ export async function POST(request: NextRequest) { } const slot = getSlot(request, bodySlot); - // Trusted (admin-configured) URLs skip the upstream re-fetch, but we - // still bind the cookie's `username` to the credential when we can verify - // locally. Without this, a caller can POST username="admin@host" + - // authHeader=, and downstream consumers that read - // the cookie-derived username (audit logs, login tracker) accept the - // spoof. Bearer tokens are opaque so only the format check runs; - // authorization sinks must key off the credential itself, not the - // cookie's username claim (see admin/auth's authHeader-hashed cache key). - let normalizedServerUrl: string; - if (upstreamTrusted) { - validateProxyAuthHeader(authHeader); - assertBasicAuthMatchesUsername(authHeader, username); - normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl); - } else { - normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); - } + // Always verify the credential upstream and bind the cookie's `username` + // to it (GHSA-wxcm-j4jc-9fxq). The encrypted context this route writes is + // accepted as proof of identity by routes that never contact the mail + // server (settings sync, plugin-approval attribution), so minting it for + // an unchecked username lets anyone without an account read and overwrite + // any user's synced settings. Admin-configured servers are `trusted`, + // which only relaxes the public-address requirement (they may live on a + // private network) - never the credential check. + const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, { + trusted: upstreamTrusted, + }); await setStalwartAuthContext(slot, { serverUrl: normalizedServerUrl, diff --git a/lib/__tests__/auth-routes-credential-check.test.ts b/lib/__tests__/auth-routes-credential-check.test.ts new file mode 100644 index 000000000..00425df3f --- /dev/null +++ b/lib/__tests__/auth-routes-credential-check.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * GHSA-wxcm-j4jc-9fxq: /api/auth/session and /api/auth/stalwart-context used + * to mint the encrypted identity cookies for any username when the requested + * server was the admin-configured one, without checking the credential + * upstream. Routes that trust the cookie's username claim without contacting + * the mail server (settings sync, plugin-approval attribution) then accepted + * the forged identity. Both routes must now verify upstream and bind the + * username to the credential before writing anything. + */ + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +const cookieSet = vi.fn(); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: () => undefined, + set: cookieSet, + delete: vi.fn(), + getAll: () => [], + }), +})); + +const config: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback), + }, +})); + +const encryptSession = vi.fn((..._args: unknown[]) => 'encrypted-session'); +vi.mock('@/lib/auth/crypto', () => ({ + encryptSession: (...args: unknown[]) => encryptSession(...args), + decryptSession: () => null, + decryptPayload: () => null, + encryptPayload: () => 'encrypted', +})); + +const setStalwartAuthContext = vi.fn(); +const setStalwartAuthContextInStore = vi.fn(); +vi.mock('@/lib/stalwart/auth-context', () => ({ + setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args), + setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args), + clearStalwartAuthContextInStore: vi.fn(), +})); + +const recordLogin = vi.fn(); +vi.mock('@/lib/telemetry/login-tracker', () => ({ + recordLogin: (...args: unknown[]) => recordLogin(...args), +})); + +const SERVER = 'https://mail.example.org'; +const HOST = 'webmail.example'; + +/** The mail server as the routes see it: who exists, and which token is whose. */ +const PASSWORDS: Record = { + 'alice@example.org': 'alice-secret', + 'mallory@example.org': 'mallory-secret', + // OAuth/SSO login name that differs from the primary identity e-mail. + 'lrath': 'linus-secret', +}; +const TOKENS: Record = { + 'tok-mallory': 'mallory@example.org', + 'tok-linus': 'lrath', +}; +const IDENTITY_EMAILS: Record = { + 'mallory@example.org': ['mallory@example.org'], + 'lrath': ['linus@example.org'], +}; + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); +} + +function authenticate(init?: RequestInit): string | null { + const auth = new Headers(init?.headers).get('authorization') || ''; + const basic = /^Basic\s+(\S+)$/i.exec(auth); + if (basic) { + const [user, pass] = Buffer.from(basic[1], 'base64').toString('utf8').split(':'); + return PASSWORDS[user] === pass ? user : null; + } + const bearer = /^Bearer\s+(\S+)$/i.exec(auth); + if (bearer) return TOKENS[bearer[1]] ?? null; + return null; +} + +let fetchSpy: ReturnType; +let identityGetCalls: number; + +function installFakeServer() { + identityGetCalls = 0; + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input); + const user = authenticate(init); + if (!user) { + return json({ type: 'about:blank', status: 401, title: 'Unauthorized' }, 401); + } + if (url === `${SERVER}/.well-known/jmap`) { + return json({ + // Advertised on a hostname this process cannot resolve, like Stalwart does. + apiUrl: 'https://stwtest.local/jmap/', + username: user, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' }, + accounts: { c: { name: user } }, + }); + } + if (url === `${SERVER}/jmap/` && init?.method === 'POST') { + identityGetCalls++; + const list = (IDENTITY_EMAILS[user] ?? []).map((email, i) => ({ id: `i${i}`, email, name: user })); + return json({ methodResponses: [['Identity/get', { accountId: 'c', list, notFound: [] }, '0']] }); + } + return json({ error: `unexpected ${init?.method ?? 'GET'} ${url}` }, 404); + }); +} + +type HeaderMap = Record; +const SAME_ORIGIN_FETCH: HeaderMap = { + host: HOST, + origin: `https://${HOST}`, + 'content-type': 'application/json', + 'sec-fetch-site': 'same-origin', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', +}; + +function mockRequest(body: unknown) { + return { + method: 'POST', + json: async () => body, + headers: { get: (k: string) => SAME_ORIGIN_FETCH[k.toLowerCase()] ?? null }, + nextUrl: { searchParams: new URLSearchParams(), origin: `https://${HOST}` }, + }; +} + +type RouteResponse = { status: number; json: () => Promise<{ error?: string; ok?: boolean }> }; + +async function postSession(body: unknown) { + const { POST } = await import('@/app/api/auth/session/route'); + const res = (await POST(mockRequest(body) as unknown as Parameters[0])) as unknown as RouteResponse; + return { status: res.status, body: await res.json() }; +} + +async function postContext(body: unknown) { + const { POST } = await import('@/app/api/auth/stalwart-context/route'); + const res = (await POST(mockRequest(body) as unknown as Parameters[0])) as unknown as RouteResponse; + return { status: res.status, body: await res.json() }; +} + +function basic(user: string, pass: string): string { + return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`; +} + +beforeEach(() => { + for (const k of Object.keys(config)) delete config[k]; + config.jmapServerUrl = SERVER; + cookieSet.mockClear(); + encryptSession.mockClear(); + setStalwartAuthContext.mockClear(); + setStalwartAuthContextInStore.mockClear(); + recordLogin.mockClear(); + vi.resetModules(); + installFakeServer(); +}); + +afterEach(() => { + fetchSpy.mockRestore(); +}); + +describe('POST /api/auth/session (GHSA-wxcm-j4jc-9fxq)', () => { + it('refuses to mint cookies for a wrong password on the configured server', async () => { + const { status, body } = await postSession({ + serverUrl: SERVER, username: 'alice@example.org', password: 'TOTALLY-WRONG', slot: 0, + }); + expect(status).toBe(401); + expect(body.error).toMatch(/authentication failed/i); + expect(cookieSet).not.toHaveBeenCalled(); + expect(setStalwartAuthContextInStore).not.toHaveBeenCalled(); + expect(recordLogin).not.toHaveBeenCalled(); + }); + + it('refuses to mint cookies for a user that does not exist upstream', async () => { + const { status } = await postSession({ + serverUrl: SERVER, username: 'nobody@example.org', password: 'x', slot: 0, + }); + expect(status).toBe(401); + expect(cookieSet).not.toHaveBeenCalled(); + expect(setStalwartAuthContextInStore).not.toHaveBeenCalled(); + }); + + it('writes no cookie when the upstream server cannot be reached', async () => { + fetchSpy.mockRejectedValue(new TypeError('fetch failed')); + const { status } = await postSession({ + serverUrl: SERVER, username: 'alice@example.org', password: 'alice-secret', slot: 0, + }); + expect(status).toBe(502); + expect(cookieSet).not.toHaveBeenCalled(); + expect(setStalwartAuthContextInStore).not.toHaveBeenCalled(); + }); + + it('still mints cookies for the right password after checking upstream', async () => { + const { status, body } = await postSession({ + serverUrl: SERVER, username: 'alice@example.org', password: 'alice-secret', slot: 0, + }); + expect(status).toBe(200); + expect(body.ok).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith( + `${SERVER}/.well-known/jmap`, + expect.objectContaining({ headers: { Authorization: basic('alice@example.org', 'alice-secret') } }), + ); + expect(cookieSet).toHaveBeenCalledTimes(1); + expect(setStalwartAuthContextInStore).toHaveBeenCalledWith( + expect.anything(), 0, expect.objectContaining({ username: 'alice@example.org', serverUrl: SERVER }), + ); + }); +}); + +describe('POST /api/auth/stalwart-context (GHSA-wxcm-j4jc-9fxq)', () => { + it('refuses a Basic credential the server rejects', async () => { + const { status } = await postContext({ + serverUrl: SERVER, username: 'alice@example.org', authHeader: basic('alice@example.org', 'nope'), slot: 0, + }); + expect(status).toBe(401); + expect(setStalwartAuthContext).not.toHaveBeenCalled(); + }); + + it('refuses a Basic credential whose user part differs from the claimed username, before any fetch', async () => { + const { status, body } = await postContext({ + serverUrl: SERVER, username: 'alice@example.org', authHeader: basic('mallory@example.org', 'mallory-secret'), slot: 0, + }); + expect(status).toBe(400); + expect(body.error).toMatch(/does not match/i); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(setStalwartAuthContext).not.toHaveBeenCalled(); + }); + + it('refuses a valid Bearer token whose owner is not the claimed username', async () => { + const { status, body } = await postContext({ + serverUrl: SERVER, username: 'alice@example.org', authHeader: 'Bearer tok-mallory', slot: 0, + }); + expect(status).toBe(403); + expect(body.error).toMatch(/does not match/i); + expect(identityGetCalls).toBe(1); + expect(setStalwartAuthContext).not.toHaveBeenCalled(); + }); + + it('refuses a Bearer token when the identity lookup fails, rather than trusting the claim', async () => { + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/.well-known/jmap')) { + return json({ apiUrl: `${SERVER}/jmap/`, username: 'lrath', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' }, accounts: {} }); + } + return json({ error: 'boom' }, 500); + }); + const { status } = await postContext({ + serverUrl: SERVER, username: 'linus@example.org', authHeader: 'Bearer tok-linus', slot: 0, + }); + expect(status).toBe(502); + expect(setStalwartAuthContext).not.toHaveBeenCalled(); + }); + + it('accepts a Bearer token whose session username is the claim, without an identity lookup', async () => { + const { status } = await postContext({ + serverUrl: SERVER, username: 'mallory@example.org', authHeader: 'Bearer tok-mallory', slot: 1, + }); + expect(status).toBe(200); + expect(identityGetCalls).toBe(0); + expect(setStalwartAuthContext).toHaveBeenCalledWith(1, expect.objectContaining({ username: 'mallory@example.org' })); + }); + + it('accepts an OAuth login registered under the primary identity e-mail via Identity/get on the reachable host', async () => { + const { status } = await postContext({ + serverUrl: SERVER, username: 'linus@example.org', authHeader: 'Bearer tok-linus', slot: 0, + }); + expect(status).toBe(200); + expect(identityGetCalls).toBe(1); + // apiUrl was advertised on stwtest.local; the lookup must go to the verified origin. + expect(fetchSpy).toHaveBeenCalledWith(`${SERVER}/jmap/`, expect.objectContaining({ method: 'POST' })); + expect(setStalwartAuthContext).toHaveBeenCalledWith(0, expect.objectContaining({ username: 'linus@example.org' })); + }); + + it('accepts the bare local part of the session username (Stalwart canonicalizes it)', async () => { + const { status } = await postContext({ + serverUrl: SERVER, username: 'lrath@corp.example', authHeader: 'Bearer tok-linus', slot: 0, + }); + expect(status).toBe(200); + expect(identityGetCalls).toBe(0); + }); + + it('still mints the context for a Basic credential the server accepts', async () => { + const { status } = await postContext({ + serverUrl: SERVER, username: 'alice@example.org', authHeader: basic('alice@example.org', 'alice-secret'), slot: 2, + }); + expect(status).toBe(200); + expect(setStalwartAuthContext).toHaveBeenCalledWith(2, expect.objectContaining({ username: 'alice@example.org', serverUrl: SERVER })); + }); +}); diff --git a/lib/__tests__/auth-routes-same-origin.test.ts b/lib/__tests__/auth-routes-same-origin.test.ts index 2c0fd242d..00d89b808 100644 --- a/lib/__tests__/auth-routes-same-origin.test.ts +++ b/lib/__tests__/auth-routes-same-origin.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; /** * GHSA-qvr9-m8cq-7wvg: the unauthenticated /api/auth/* routes write @@ -150,12 +150,26 @@ describe('isSameOriginRequest', () => { }); describe('POST /api/auth/session (GHSA-qvr9-m8cq-7wvg session fixation)', () => { + let fetchSpy: ReturnType; + beforeEach(() => { for (const k of Object.keys(config)) delete config[k]; config.jmapServerUrl = TRUSTED; cookieSet.mockClear(); setStalwartAuthContextInStore.mockClear(); vi.resetModules(); + // The route now verifies the credential upstream even for the configured + // server (GHSA-wxcm-j4jc-9fxq); stand in for a server that accepts it. + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ apiUrl: `${TRUSTED}/jmap/`, username: SESSION_BODY.username, accounts: {} }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + }); + + afterEach(() => { + fetchSpy.mockRestore(); }); it('refuses a cross-site form POST before reading the body and writes no cookie', async () => { diff --git a/lib/__tests__/verify-jmap-identity.test.ts b/lib/__tests__/verify-jmap-identity.test.ts new file mode 100644 index 000000000..2487ed6bb --- /dev/null +++ b/lib/__tests__/verify-jmap-identity.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * verifyJmapIdentity binds a username claim to a verified credential + * (GHSA-wxcm-j4jc-9fxq). The route-level behaviour for admin-configured + * servers is covered in auth-routes-credential-check.test.ts; this file + * covers the matching rules and the user-chosen (untrusted) endpoint path, + * which must keep every hop on the rebinding-safe fetch. + */ + +const lookup = vi.fn(); +const guardedFetch = vi.fn(); + +vi.mock('@/lib/security/url-guard', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchPublicUrl: (...args: unknown[]) => guardedFetch(...args), + }; +}); + +vi.mock('node:dns/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) }, + lookup: (...args: unknown[]) => lookup(...args), + }; +}); + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); +} + +function basic(user: string, pass: string): string { + return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`; +} + +async function load() { + return import('@/lib/auth/verify-jmap-auth'); +} + +describe('usernameMatchesSession', () => { + it('matches exact and case-different login names', async () => { + const { usernameMatchesSession } = await load(); + expect(usernameMatchesSession('usera@example.org', 'usera@example.org')).toBe(true); + expect(usernameMatchesSession('UserA@Example.org', 'usera@example.org')).toBe(true); + }); + + it('matches a bare local part against the canonical e-mail login on either side', async () => { + const { usernameMatchesSession } = await load(); + expect(usernameMatchesSession('usera', 'usera@example.org')).toBe(true); + expect(usernameMatchesSession('linus@example.org', 'linus')).toBe(true); + }); + + it('rejects different users, different domains and missing session usernames', async () => { + const { usernameMatchesSession } = await load(); + expect(usernameMatchesSession('alice@example.org', 'mallory@example.org')).toBe(false); + expect(usernameMatchesSession('alice@example.org', 'alice@other.example')).toBe(false); + expect(usernameMatchesSession('alice', 'alicia@example.org')).toBe(false); + expect(usernameMatchesSession('alice@example.org', undefined)).toBe(false); + expect(usernameMatchesSession('alice@example.org', '')).toBe(false); + }); +}); + +describe('verifyJmapIdentity', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + lookup.mockReset(); + guardedFetch.mockReset(); + vi.resetModules(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('rejects an empty username before any fetch', async () => { + const { verifyJmapIdentity } = await load(); + await expect(verifyJmapIdentity('https://example.com', 'Bearer x', '', { trusted: true })) + .rejects.toMatchObject({ status: 400 }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a Basic credential for another user before any fetch', async () => { + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://example.com', basic('mallory@example.org', 'pw'), 'alice@example.org', { trusted: true }), + ).rejects.toMatchObject({ status: 400 }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('surfaces the upstream 401 for a Basic credential the server rejects', async () => { + fetchSpy.mockResolvedValueOnce(json({ status: 401 }, 401)); + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://mail.internal', basic('alice@example.org', 'wrong'), 'alice@example.org', { trusted: true }), + ).rejects.toMatchObject({ status: 401, upstreamStatus: 401 }); + }); + + it('accepts a Basic credential the server accepts without inspecting Session.username', async () => { + // Stalwart canonicalizes the login; a Basic caller who typed an alias is + // still proven by the server accepting exactly that user:password pair. + fetchSpy.mockResolvedValueOnce(json({ apiUrl: 'https://mail.internal/jmap/', username: 'alice', accounts: {} })); + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://mail.internal/', basic('a.smith@example.org', 'pw'), 'a.smith@example.org', { trusted: true }), + ).resolves.toBe('https://mail.internal'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('on a user-chosen endpoint, runs the identity lookup through the guarded fetch on the verified origin', async () => { + lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + guardedFetch + .mockResolvedValueOnce(json({ + apiUrl: 'https://public.example.com:8443/jmap/', + username: 'linus', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' }, + accounts: {}, + })) + .mockResolvedValueOnce(json({ + methodResponses: [['Identity/get', { accountId: 'c', list: [{ id: 'i0', email: 'Linus.Rath@example.com' }] }, '0']], + })); + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://example.com', 'Bearer tok', 'linus.rath@example.com', { trusted: false }), + ).resolves.toBe('https://example.com'); + expect(guardedFetch).toHaveBeenCalledTimes(2); + expect(guardedFetch).toHaveBeenLastCalledWith( + 'https://example.com/jmap/', + expect.objectContaining({ method: 'POST' }), + ); + const body = JSON.parse((guardedFetch.mock.calls[1][1] as { body: string }).body); + expect(body.methodCalls[0][0]).toBe('Identity/get'); + expect(body.methodCalls[0][1]).toEqual({ accountId: 'c', ids: null }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a Bearer claim that neither Session.username nor any identity confirms', async () => { + fetchSpy + .mockResolvedValueOnce(json({ + apiUrl: 'https://mail.internal/jmap/', + username: 'mallory@example.org', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' }, + accounts: {}, + })) + .mockResolvedValueOnce(json({ + methodResponses: [['Identity/get', { accountId: 'c', list: [{ id: 'i0', email: 'mallory@example.org' }] }, '0']], + })); + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://mail.internal', 'Bearer tok', 'alice@example.org', { trusted: true }), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('rejects a Bearer claim when the session has no primary mail account to look identities up on', async () => { + fetchSpy.mockResolvedValueOnce(json({ apiUrl: 'https://mail.internal/jmap/', username: 'mallory', accounts: {} })); + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://mail.internal', 'Bearer tok', 'alice@example.org', { trusted: true }), + ).rejects.toMatchObject({ status: 403 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('treats a malformed Identity/get response as a failure, not as a match', async () => { + fetchSpy + .mockResolvedValueOnce(json({ + apiUrl: 'https://mail.internal/jmap/', + username: 'mallory', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' }, + accounts: {}, + })) + .mockResolvedValueOnce(new Response('not json', { status: 200 })); + const { verifyJmapIdentity } = await load(); + await expect( + verifyJmapIdentity('https://mail.internal', 'Bearer tok', 'alice@example.org', { trusted: true }), + ).rejects.toMatchObject({ status: 502 }); + }); +}); diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 71c00896c..8abde7e9b 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -87,11 +87,196 @@ export function assertBasicAuthMatchesUsername(authHeader: string, claimedUserna } } +export interface VerifiedJmapSession { + apiUrl: string; + /** Canonical login name the server reports for the credential (RFC 8620 ยง2). */ + username?: string; + primaryAccounts?: Record; + accounts: Record; +} + +export interface VerifyJmapAuthOptions { + trusted?: boolean; +} + +/** + * Verify `authHeader` against `serverUrl` and return the normalized URL. + * Callers that need to bind a username claim to the credential must use + * {@link verifyJmapIdentity} instead: a valid credential alone says nothing + * about *which* user it belongs to. + */ export async function verifyJmapAuth( serverUrl: string, authHeader: string, - options: { trusted?: boolean } = {}, + options: VerifyJmapAuthOptions = {}, ): Promise { + const { serverUrl: normalizedServerUrl } = await fetchVerifiedSession(serverUrl, authHeader, options); + return normalizedServerUrl; +} + +function usernamesEqual(a: string, b: string): boolean { + return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0; +} + +/** + * Does the claimed login name denote the server-reported one? Stalwart + * canonicalizes the login it echoes in `Session.username` (`UserA@x` and the + * bare local part `usera` both come back as `usera@x`), so besides a + * case-insensitive match we accept a bare local part on either side. + */ +export function usernameMatchesSession(claimed: string, sessionUsername: string | undefined): boolean { + if (!sessionUsername) return false; + if (usernamesEqual(claimed, sessionUsername)) return true; + const claimedAt = claimed.indexOf('@'); + const sessionAt = sessionUsername.indexOf('@'); + if (claimedAt < 0 && sessionAt > 0) { + return usernamesEqual(claimed, sessionUsername.slice(0, sessionAt)); + } + if (sessionAt < 0 && claimedAt > 0) { + return usernamesEqual(claimed.slice(0, claimedAt), sessionUsername); + } + return false; +} + +/** + * Verify `authHeader` upstream AND prove that `claimedUsername` is the user + * that credential authenticates (GHSA-wxcm-j4jc-9fxq). The identity cookies + * minted from this result are consumed by routes that trust the cookie's + * username claim without contacting the mail server (settings sync, plugin + * approval attribution), so an unbound claim is a cross-user forgery. + * + * - Basic: the credential's user part must equal the claim; the upstream + * session fetch then proves that user's password is right. + * - Bearer: the token is opaque, so the claim is checked against the + * server's `Session.username`, and failing that against the e-mail + * addresses of the account's sending identities (OAuth/SSO logins register + * under the primary identity's e-mail, which may differ from the login). + * + * Returns the normalized server URL. + */ +export async function verifyJmapIdentity( + serverUrl: string, + authHeader: string, + claimedUsername: string, + options: VerifyJmapAuthOptions = {}, +): Promise { + if (!claimedUsername) { + throw new JmapAuthVerificationError('Missing username', 400); + } + validateProxyAuthHeader(authHeader); + const isBasic = /^Basic\s/i.test(authHeader); + if (isBasic) { + assertBasicAuthMatchesUsername(authHeader, claimedUsername); + } + + const { serverUrl: normalizedServerUrl, session } = await fetchVerifiedSession(serverUrl, authHeader, options); + if (isBasic) { + return normalizedServerUrl; + } + + if (usernameMatchesSession(claimedUsername, session.username)) { + return normalizedServerUrl; + } + + const identityEmails = await fetchIdentityEmails(normalizedServerUrl, authHeader, session, options); + if (identityEmails.some((email) => usernamesEqual(email, claimedUsername))) { + return normalizedServerUrl; + } + + throw new JmapAuthVerificationError('Username does not match credentials', 403); +} + +/** + * Rebase the session's advertised `apiUrl` onto the origin we verified + * against: the advertised public hostname may not resolve from this process + * (mirrors `rebaseApiUrl` in `@/lib/stalwart/jmap-api`). + */ +function rebaseApiUrl(apiUrl: string, normalizedServerUrl: string): string { + const base = new URL(normalizedServerUrl); + const api = new URL(apiUrl, `${normalizedServerUrl}/`); + return new URL(api.pathname + api.search, base.origin).toString(); +} + +async function fetchIdentityEmails( + normalizedServerUrl: string, + authHeader: string, + session: VerifiedJmapSession, + options: VerifyJmapAuthOptions, +): Promise { + const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail']; + if (typeof accountId !== 'string' || !accountId) return []; + + let apiUrl: string; + try { + apiUrl = rebaseApiUrl(session.apiUrl, normalizedServerUrl); + } catch { + throw new JmapAuthVerificationError('Invalid JMAP session response', 502); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS); + try { + if (!options.trusted && !(await isPublicHttpUrl(apiUrl))) { + throw new JmapAuthVerificationError('Server URL is not allowed', 400); + } + const requestInit = { + method: 'POST', + headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [['Identity/get', { accountId, ids: null }, '0']], + }), + signal: controller.signal, + }; + // No redirect handling here: a redirect on the POST would downgrade it to + // a GET and can only come from a misconfigured proxy - treat as failure. + const response = options.trusted + ? await fetch(apiUrl, { ...requestInit, redirect: 'manual' }) + : await fetchPublicUrl(apiUrl, requestInit); + if (!response.ok) { + throw new JmapAuthVerificationError( + response.status === 401 || response.status === 403 + ? 'Authentication failed' + : 'Failed to verify JMAP identity', + response.status === 401 || response.status === 403 ? 401 : 502, + response.status, + ); + } + const body = await response.json().catch(() => null) as { methodResponses?: unknown } | null; + const responses = Array.isArray(body?.methodResponses) ? body.methodResponses : null; + if (!responses) { + throw new JmapAuthVerificationError('Invalid JMAP response', 502); + } + const emails: string[] = []; + for (const entry of responses) { + if (!Array.isArray(entry) || entry[0] !== 'Identity/get') continue; + const list = (entry[1] as { list?: unknown })?.list; + if (!Array.isArray(list)) continue; + for (const identity of list) { + const email = (identity as { email?: unknown })?.email; + if (typeof email === 'string' && email) emails.push(email); + } + } + return emails; + } catch (error) { + if (error instanceof JmapAuthVerificationError) throw error; + if (error instanceof DisallowedUrlError) { + throw new JmapAuthVerificationError('Server URL is not allowed', 400); + } + if (error instanceof Error && error.name === 'AbortError') { + throw new JmapAuthVerificationError('JMAP identity verification timed out', 504); + } + throw new JmapAuthVerificationError('Failed to verify JMAP identity', 502); + } finally { + clearTimeout(timeout); + } +} + +async function fetchVerifiedSession( + serverUrl: string, + authHeader: string, + options: VerifyJmapAuthOptions, +): Promise<{ serverUrl: string; session: VerifiedJmapSession }> { const normalizedServerUrl = normalizeJmapServerUrl(serverUrl); validateProxyAuthHeader(authHeader); @@ -152,12 +337,24 @@ export async function verifyJmapAuth( ); } - const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null; + const session = await response.json().catch(() => null) as + { apiUrl?: unknown; accounts?: unknown; username?: unknown; primaryAccounts?: unknown } | null; if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) { throw new JmapAuthVerificationError('Invalid JMAP session response', 502); } - return normalizedServerUrl; + return { + serverUrl: normalizedServerUrl, + session: { + apiUrl: session.apiUrl, + username: typeof session.username === 'string' ? session.username : undefined, + primaryAccounts: + session.primaryAccounts && typeof session.primaryAccounts === 'object' + ? (session.primaryAccounts as Record) + : undefined, + accounts: session.accounts as Record, + }, + }; } catch (error) { if (error instanceof JmapAuthVerificationError) { throw error;