From 11eb0bfb16284b6df6ea4837b6f247039f1fd2a7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:05:05 +0200 Subject: [PATCH] fix: keep the Stalwart master password out of impersonated sessions An impersonation handoff sealed `%` and the master password into the session cookie, and `PUT /api/auth/session` hands that cookie's contents to the browser on every restore. Anyone who received a handoff could read a credential that signs in as every mailbox and into Stalwart administration, with no expiry and no audit trail. The impersonate route now uses the master credential server-side, once, to create an app password on the target mailbox that expires after 8 hours. Only that app password reaches the cookies. Signing out revokes it through a sealed grant cookie, and PUT refuses any session that still holds the configured master password, so cookies minted before this change are dropped instead of returned. --- .env.example | 5 + app/api/auth/impersonate/route.ts | 58 +++++-- app/api/auth/session/route.ts | 41 ++++- lib/__tests__/impersonation-session.test.ts | 160 ++++++++++++++++++++ lib/auth/crypto.ts | 5 +- lib/impersonation/app-password.ts | 117 ++++++++++++++ lib/impersonation/grant-cookie.ts | 29 ++++ 7 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 lib/__tests__/impersonation-session.test.ts create mode 100644 lib/impersonation/app-password.ts create mode 100644 lib/impersonation/grant-cookie.ts diff --git a/.env.example b/.env.example index 143a50a0a..2f9d4299f 100644 --- a/.env.example +++ b/.env.example @@ -450,6 +450,11 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # them unset keeps the feature fully off. Treat the secret and the master # password as you would a root credential. # +# The master password never leaves the server: each handoff uses it once to +# create an app password on the target mailbox, valid for 8 hours and +# revoked on sign-out. The target account's role therefore needs the +# app-password permissions (Stalwart's default user role has them). +# # BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters # BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com # BULWARK_STALWART_MASTER_PASSWORD= # required diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 858ccb8ef..bef1b35f4 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -16,6 +16,8 @@ import { readImpersonationConfig, resolveImpersonationServerUrl, } from '@/lib/impersonation/master-config'; +import { mintImpersonationCredential } from '@/lib/impersonation/app-password'; +import { IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant } from '@/lib/impersonation/grant-cookie'; export const runtime = 'nodejs'; @@ -40,8 +42,13 @@ function impersonationCookieOptions() { * mailbox; Bulwark verifies the signature, resolves the configured Stalwart * master credentials from env, then mints the same session cookies the * password-login path produces. The browser is redirected to "/?impersonated=1" (see - * ImpersonationReconciler, GH #646) and the - * SPA hydrates as if the user had just logged in with master@target%master. + * ImpersonationReconciler, GH #646) and the SPA hydrates as if the user had + * just logged in to the target mailbox. + * + * The master credential is used here, server-side, only to create an + * expiring app password on the target mailbox. The session cookies carry + * that app password: the browser reads its session back through + * PUT /api/auth/session, and the master password would open every mailbox. * * Returns 404 when the feature is not configured so an unconfigured * deployment does not advertise the endpoint. @@ -92,26 +99,44 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 }); } - // Stalwart master-user impersonation: username = "%", - // password = . Per Stalwart docs: + // Stalwart master-user login ("%", master password) opens + // the target mailbox; use it once to mint the target's own app password. // https://stalw.art/docs/auth/authorization/administrator/ - const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`; - const authHeader = `Basic ${Buffer.from( - `${impersonatedUsername}:${config.masterPassword}`, - ).toString('base64')}`; + let credential; + try { + credential = await mintImpersonationCredential({ + serverUrl: normalizedServerUrl, + mailbox: claims.mailbox, + masterUser: config.masterUser, + masterPassword: config.masterPassword, + description: `Support session ${claims.jti}`.slice(0, 100), + }); + } catch (err) { + logger.error('Impersonation credential could not be created', { + jti: claims.jti, + mailbox: claims.mailbox, + error: err instanceof Error ? err.message : 'Unknown', + }); + return NextResponse.json({ error: 'Could not open the mailbox' }, { status: 502 }); + } const cookieStore = await cookies(); - const sessionToken = encryptSession( - normalizedServerUrl, - impersonatedUsername, - config.masterPassword, - ); + const sessionToken = encryptSession(normalizedServerUrl, claims.mailbox, credential.secret); cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions()); setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, { serverUrl: normalizedServerUrl, - username: impersonatedUsername, - authHeader, + username: claims.mailbox, + authHeader: `Basic ${Buffer.from(`${claims.mailbox}:${credential.secret}`).toString('base64')}`, }); + cookieStore.set( + IMPERSONATION_GRANT_COOKIE, + sealImpersonationGrant({ + serverUrl: normalizedServerUrl, + mailbox: claims.mailbox, + credentialId: credential.id, + }), + impersonationCookieOptions(), + ); // Structured audit log - operators rely on this for security review. logger.info('Impersonation session granted', { @@ -121,6 +146,7 @@ export async function GET(request: NextRequest) { tenant_id: claims.tenant_id, actor_user_id: claims.actor_user_id, iss: claims.iss, + expires_at: credential.expiresAt, ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || @@ -129,7 +155,7 @@ export async function GET(request: NextRequest) { user_agent: request.headers.get('user-agent'), }); - void recordLogin(impersonatedUsername, normalizedServerUrl); + void recordLogin(claims.mailbox, normalizedServerUrl); // Use a relative Location header so the browser resolves it against the // public request URL. NextResponse.redirect(new URL('/', request.url)) diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index a2a8a3aec..765cc4b81 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -16,6 +16,9 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure'; +import { readImpersonationConfig } from '@/lib/impersonation/master-config'; +import { revokeImpersonationCredential } from '@/lib/impersonation/app-password'; +import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie'; function sessionCookieOptions() { return { @@ -24,6 +27,40 @@ function sessionCookieOptions() { }; } +/** + * A session sealed around the impersonation master password. Handoffs made + * before impersonation switched to per-mailbox app passwords stored it in + * the session cookie; it opens every mailbox, so it is never handed back to + * a browser, whichever path stored it. + */ +function holdsMasterPassword(credentials: { password: string }): boolean { + const config = readImpersonationConfig(); + return !!config && credentials.password === config.masterPassword; +} + +async function revokeImpersonationGrant( + cookieStore: Awaited>, +): Promise { + const grant = openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value); + cookieStore.delete(IMPERSONATION_GRANT_COOKIE); + const config = readImpersonationConfig(); + if (!grant || !config) return; + try { + await revokeImpersonationCredential({ + serverUrl: grant.serverUrl, + mailbox: grant.mailbox, + masterUser: config.masterUser, + masterPassword: config.masterPassword, + id: grant.credentialId, + }); + } catch (error) { + // The app password still expires on its own; sign-out must not fail. + logger.warn('Impersonation credential revocation failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); if (raw === null) return 0; @@ -181,7 +218,7 @@ export async function PUT(request: NextRequest) { } const credentials = decryptSession(token); - if (!credentials) { + if (!credentials || holdsMasterPassword(credentials)) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); @@ -217,10 +254,12 @@ export async function DELETE(request: NextRequest) { cookieStore.delete(sessionCookieName(i)); clearStalwartAuthContextInStore(cookieStore, i); } + await revokeImpersonationGrant(cookieStore); } else { const slot = getSlot(request); cookieStore.delete(sessionCookieName(slot)); clearStalwartAuthContextInStore(cookieStore, slot); + if (slot === 0) await revokeImpersonationGrant(cookieStore); } return NextResponse.json({ ok: true }); diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts new file mode 100644 index 000000000..1920f148d --- /dev/null +++ b/lib/__tests__/impersonation-session.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment node +import { createHmac, randomUUID } from 'node:crypto'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/auth/session-secret', () => ({ + getSessionSecret: () => 's'.repeat(64), + hasSessionSecret: () => true, +})); +vi.mock('@/lib/logger', () => ({ + logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} }, +})); +vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} })); + +const jar = new Map(); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined), + set: (name: string, value: string) => { jar.set(name, value); }, + delete: (name: string) => { jar.delete(name); }, + getAll: () => [...jar].map(([name, value]) => ({ name, value })), + }), +})); + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => + key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback, + }, +})); + +const SECRET = 'j'.repeat(40); +const MASTER_PASSWORD = 'M4ster-P4ss-for-every-mailbox'; +const APP_SECRET = 'app-password-secret-for-alice'; + +function mintJwt(mailbox: string) { + const now = Math.floor(Date.now() / 1000); + const b64url = (s: string) => Buffer.from(s).toString('base64url'); + const h = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const p = b64url(JSON.stringify({ iss: 'platform-api/webmail', iat: now, exp: now + 60, jti: randomUUID(), mailbox })); + const sig = createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url'); + return `${h}.${p}.${sig}`; +} + +const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }; + +type Call = { url: string; auth: string | null; body: unknown }; +let calls: Call[]; +let createResult: Record; + +function stalwartFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = String(input); + const headers = new Headers(init?.headers); + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + calls.push({ url, auth: headers.get('authorization'), body }); + if (url.endsWith('/jmap/session')) { + return Promise.resolve(Response.json({ + apiUrl: 'https://mail.example.org/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-alice' }, + })); + } + const method = (body as { methodCalls: [string, Record, string][] }).methodCalls[0]; + if (method[1].create) { + return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', createResult, '0']] })); + } + return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', { destroyed: method[1].destroy }, '0']] })); +} + +async function impersonate(mailbox = 'alice@example.org') { + const { GET } = await import('@/app/api/auth/impersonate/route'); + return GET(new NextRequest(`https://webmail.example/api/auth/impersonate?token=${mintJwt(mailbox)}`)); +} + +async function readSession() { + const { PUT } = await import('@/app/api/auth/session/route'); + return PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN })); +} + +describe('impersonation handoff', () => { + beforeAll(() => { + process.env.BULWARK_JWT_AUTH_SECRET = SECRET; + process.env.BULWARK_STALWART_MASTER_USER = 'master@example.org'; + process.env.BULWARK_STALWART_MASTER_PASSWORD = MASTER_PASSWORD; + }); + + beforeEach(() => { + jar.clear(); + calls = []; + createResult = { created: { imp: { id: 'cred-1', secret: APP_SECRET } } }; + vi.stubGlobal('fetch', vi.fn(stalwartFetch)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('hands the browser an app password for the target mailbox, never the master password', async () => { + const res = await impersonate(); + expect(res.status).toBe(303); + + const put = await readSession(); + expect(put.status).toBe(200); + const body = await put.json(); + expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET }); + expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD); + expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD); + }); + + it('uses the master credential only towards Stalwart, to create an expiring app password', async () => { + const before = Date.now(); + await impersonate(); + + const masterAuth = `Basic ${Buffer.from(`alice@example.org%master@example.org:${MASTER_PASSWORD}`).toString('base64')}`; + expect(calls.every((c) => c.auth === masterAuth)).toBe(true); + const create = calls.find((c) => c.url.endsWith('/jmap/'))!.body as { + methodCalls: [string, { accountId: string; create: { imp: { expiresAt: string } } }, string][]; + }; + const [method, args] = create.methodCalls[0]; + expect(method).toBe('x:AppPassword/set'); + expect(args.accountId).toBe('acc-alice'); + expect(args.create.imp.expiresAt).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); + const ttl = Date.parse(args.create.imp.expiresAt) - before; + expect(ttl).toBeGreaterThan(7 * 3600_000); + expect(ttl).toBeLessThanOrEqual(8 * 3600_000); + }); + + it('refuses the handoff when Stalwart does not create the app password', async () => { + createResult = { notCreated: { imp: { type: 'forbidden', description: 'not allowed' } } }; + const res = await impersonate(); + expect(res.status).toBe(502); + expect(jar.size).toBe(0); + }); + + it('revokes the app password on sign-out', async () => { + await impersonate(); + calls = []; + const { DELETE } = await import('@/app/api/auth/session/route'); + const res = await DELETE(new NextRequest('https://webmail.example/api/auth/session?all=true', { + method: 'DELETE', + headers: { 'sec-fetch-site': 'same-origin', origin: 'https://webmail.example' }, + })); + expect(res.status).toBe(200); + const destroy = calls.find((c) => c.url.endsWith('/jmap/'))!.body as { + methodCalls: [string, { destroy: string[] }, string][]; + }; + expect(destroy.methodCalls[0][1].destroy).toEqual(['cred-1']); + expect(jar.size).toBe(0); + }); + + it('will not return a session that still holds the master password', async () => { + const { encryptSession } = await import('@/lib/auth/crypto'); + jar.set('jmap_session', encryptSession('https://mail.example.org', 'alice@example.org%master@example.org', MASTER_PASSWORD)); + + const put = await readSession(); + expect(put.status).toBe(401); + expect(JSON.stringify(await put.json())).not.toContain(MASTER_PASSWORD); + expect(jar.has('jmap_session')).toBe(false); + }); +}); diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 4e3032d85..ac39563a1 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -77,7 +77,9 @@ export type PayloadPurpose = /** Fresh-IdP-login proof required before a device pairing code is issued. */ | 'pair-reauth' /** WOPI access token, scoped to one file node and a six-hour expiry. */ - | 'wopi-token'; + | 'wopi-token' + /** The app password an impersonation handoff minted, kept for revocation. */ + | 'impersonation-grant'; /** * Whether a blob minted before purposes were bound may be re-admitted for @@ -95,6 +97,7 @@ const LEGACY_PAYLOAD_MARKERS: Record p.t === undefined && (p.purpose === undefined || p.purpose === 'reauth'), 'pair-reauth': (p) => p.purpose === 'pair', 'wopi-token': (p) => p.t === 'wopi', + 'impersonation-grant': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/impersonation/app-password.ts b/lib/impersonation/app-password.ts new file mode 100644 index 000000000..01f4bc57c --- /dev/null +++ b/lib/impersonation/app-password.ts @@ -0,0 +1,117 @@ +import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; + +const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']; + +/** + * How long an impersonation handoff stays valid. Stalwart enforces it through + * the app password's `expiresAt`, so the session ends even if the browser + * keeps the cookie alive. + */ +export const IMPERSONATION_SESSION_TTL_SEC = 8 * 60 * 60; + +export class ImpersonationCredentialError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImpersonationCredentialError'; + } +} + +export interface ImpersonationCredential { + id: string; + secret: string; + expiresAt: string; +} + +/** Basic auth header for Stalwart master-user login as `mailbox`. */ +export function masterAuthHeader(mailbox: string, masterUser: string, masterPassword: string): string { + return `Basic ${Buffer.from(`${mailbox}%${masterUser}:${masterPassword}`).toString('base64')}`; +} + +/** JMAP UTCDate: no fractional seconds (RFC 8620 section 1.4). */ +function utcDate(date: Date): string { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +function pickAccountId(primaryAccounts: Record | undefined): string | null { + if (!primaryAccounts) return null; + return ( + primaryAccounts['urn:ietf:params:jmap:mail'] ?? + primaryAccounts['urn:stalwart:jmap'] ?? + Object.values(primaryAccounts)[0] ?? + null + ); +} + +async function callStalwart( + serverUrl: string, + authHeader: string, + methodCall: [string, Record, string], +): Promise> { + const session = await fetchJmapSession(serverUrl, authHeader, { trusted: true }); + const accountId = pickAccountId(session?.primaryAccounts); + if (!session || !accountId) { + throw new ImpersonationCredentialError('Could not open the target mailbox with the master credential'); + } + const apiUrl = rebaseApiUrl(session, serverUrl) ?? `${serverUrl}/jmap/`; + const [method, args, callId] = methodCall; + const response = await postJmap( + apiUrl, + authHeader, + JSON.stringify({ using: STALWART_USING, methodCalls: [[method, { accountId, ...args }, callId]] }), + { trusted: true }, + ); + if (!response.ok) { + throw new ImpersonationCredentialError(`Stalwart answered HTTP ${response.status}`); + } + const data = (await response.json()) as { methodResponses?: [string, Record, string][] }; + const result = data.methodResponses?.[0]; + if (!result || result[0] === 'error') { + const detail = result?.[1] as { type?: string; description?: string } | undefined; + throw new ImpersonationCredentialError(detail?.description || detail?.type || 'Unexpected JMAP response'); + } + return result[1]; +} + +/** + * Use the master credential server-side, once, to create an expiring app + * password on the target mailbox. The browser only ever receives this app + * password: it opens that one mailbox and stops working at `expiresAt`, + * while the master password signs in as every mailbox and never expires. + */ +export async function mintImpersonationCredential(opts: { + serverUrl: string; + mailbox: string; + masterUser: string; + masterPassword: string; + description: string; + now?: Date; +}): Promise { + const now = opts.now ?? new Date(); + const expiresAt = utcDate(new Date(now.getTime() + IMPERSONATION_SESSION_TTL_SEC * 1000)); + const result = await callStalwart( + opts.serverUrl, + masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword), + ['x:AppPassword/set', { create: { imp: { description: opts.description, expiresAt } } }, '0'], + ); + const created = (result.created as Record | undefined)?.imp; + if (!created?.id || !created.secret) { + const failed = (result.notCreated as Record | undefined)?.imp; + throw new ImpersonationCredentialError(failed?.description || failed?.type || 'App password was not created'); + } + return { id: created.id, secret: created.secret, expiresAt }; +} + +/** Best-effort revocation of a credential minted by {@link mintImpersonationCredential}. */ +export async function revokeImpersonationCredential(opts: { + serverUrl: string; + mailbox: string; + masterUser: string; + masterPassword: string; + id: string; +}): Promise { + await callStalwart( + opts.serverUrl, + masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword), + ['x:AppPassword/set', { destroy: [opts.id] }, '0'], + ); +} diff --git a/lib/impersonation/grant-cookie.ts b/lib/impersonation/grant-cookie.ts new file mode 100644 index 000000000..773f4e0a2 --- /dev/null +++ b/lib/impersonation/grant-cookie.ts @@ -0,0 +1,29 @@ +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; + +/** + * Remembers which app password an impersonation handoff minted, so signing + * out can revoke it instead of leaving it valid until it expires. + * Impersonation always lands in slot 0, so one cookie is enough. + */ +export const IMPERSONATION_GRANT_COOKIE = 'jmap_impersonation'; + +export interface ImpersonationGrant { + serverUrl: string; + mailbox: string; + credentialId: string; +} + +export function sealImpersonationGrant(grant: ImpersonationGrant): string { + return encryptPayload({ ...grant }, 'impersonation-grant'); +} + +export function openImpersonationGrant(token: string | undefined): ImpersonationGrant | null { + if (!token) return null; + const payload = decryptPayload(token, 'impersonation-grant'); + if (!payload) return null; + const { serverUrl, mailbox, credentialId } = payload; + if (typeof serverUrl !== 'string' || typeof mailbox !== 'string' || typeof credentialId !== 'string') { + return null; + } + return { serverUrl, mailbox, credentialId }; +}