From 6ffa0a3715e663cbe324c73cfcdc038c1aa28cc4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:23:23 +0100 Subject: [PATCH] fix: show the real sender and subject for a push after the browser was closed --- app/api/push/action/route.ts | 5 +- app/api/push/preview/route.ts | 4 +- lib/__tests__/push-auth-context.test.ts | 65 +++++++++++++++++++ .../push-preview-route-ambiguous.test.ts | 21 ++++++ lib/stalwart/push-auth-context.ts | 31 +++++++++ 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/push-auth-context.test.ts create mode 100644 lib/stalwart/push-auth-context.ts diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts index 7ad419076..2e3d431d6 100644 --- a/app/api/push/action/route.ts +++ b/app/api/push/action/route.ts @@ -10,7 +10,8 @@ import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { DisallowedUrlError } from '@/lib/security/url-guard'; -import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import type { StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import type { SettingsPolicy } from '@/lib/admin/types'; import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; @@ -98,7 +99,7 @@ async function loginsNamed(login: string): Promise { const cookieStore = await cookies(); const found: StalwartAuthContext[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + const ctx = await readPushAuthContext(cookieStore, slot); if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx); } return found; diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 2c214351c..68ba99d35 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { cleanPreview } from '@/lib/utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; -import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; import { getStalwartCredentials, type StalwartCredentials, @@ -54,7 +54,7 @@ async function resolveTargetForAccount(accountId: string, login: string | null, const cookieStore = await cookies(); const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + const ctx = await readPushAuthContext(cookieStore, slot); if (!ctx) continue; const serverUrl = ctx.serverUrl.replace(/\/+$/, ''); probes.push( diff --git a/lib/__tests__/push-auth-context.test.ts b/lib/__tests__/push-auth-context.test.ts new file mode 100644 index 000000000..33cef70d5 --- /dev/null +++ b/lib/__tests__/push-auth-context.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The context cookie lasts as long as the browser session. With every window +// closed and the browser started again by a push, only the remembered session +// is left, and the worker has to make do with that or show "New mail". +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); +const maxHours = vi.hoisted(() => ({ value: 0 })); +vi.mock('@/lib/auth/session-age', () => ({ + isPastMaxAge: (signedInAt: number, hours: number) => hours > 0 && Date.now() - signedInAt > hours * 3600_000, + limitsEnforcedSince: async () => Date.now(), + maxSessionHoursFor: async () => maxHours.value, +})); +const impersonation = vi.hoisted(() => ({ master: null as string | null })); +vi.mock('@/lib/impersonation/master-config', () => ({ + readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null), +})); + +import { encryptSession } from '@/lib/auth/crypto'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; +import { stalwartAuthContextCookieName } from '@/lib/stalwart/auth-context'; +import { encryptPayload } from '@/lib/auth/crypto'; + +function store(cookies: Record) { + return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) } as never; +} + +const remembered = (signedInAt = Date.now(), password = 'pw') => encryptSession('https://mail.example.org', 'me@example.org', password, signedInAt); + +beforeEach(() => { + process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789'; + maxHours.value = 0; + impersonation.master = null; +}); + +describe('readPushAuthContext', () => { + it('prefers the context cookie', async () => { + const context = encryptPayload({ serverUrl: 'https://ctx.example.org', username: 'ctx@example.org', authHeader: 'Bearer t' }, 'session-context'); + const found = await readPushAuthContext(store({ [stalwartAuthContextCookieName(0)]: context, [sessionCookieName(0)]: remembered() }), 0); + expect(found?.username).toBe('ctx@example.org'); + }); + + it('falls back to the remembered session', async () => { + const found = await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 1); + expect(found).toMatchObject({ serverUrl: 'https://mail.example.org', username: 'me@example.org' }); + expect(found?.authHeader).toMatch(/^Basic /); + }); + + it('finds nothing in another slot, or without either cookie', async () => { + expect(await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 0)).toBeNull(); + expect(await readPushAuthContext(store({}), 0)).toBeNull(); + }); + + it('refuses a session past the organisation\'s maximum length', async () => { + maxHours.value = 1; + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now() - 2 * 3600_000) }), 0)).toBeNull(); + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull(); + }); + + it('refuses garbage and a cookie holding the impersonation master password', async () => { + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull(); + impersonation.master = 'master-secret'; + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now(), 'master-secret') }), 0)).toBeNull(); + }); +}); diff --git a/lib/__tests__/push-preview-route-ambiguous.test.ts b/lib/__tests__/push-preview-route-ambiguous.test.ts index 93e74d40f..eeeb41d28 100644 --- a/lib/__tests__/push-preview-route-ambiguous.test.ts +++ b/lib/__tests__/push-preview-route-ambiguous.test.ts @@ -30,9 +30,14 @@ const LOGINS = [ { serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' }, { serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' }, ]; +const ORIGINAL_LOGINS = [...LOGINS]; vi.mock('@/lib/stalwart/auth-context', () => ({ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, })); +const remembered = vi.hoisted(() => ({ value: null as null | { serverUrl: string; authHeader: string; username: string } })); +vi.mock('@/lib/stalwart/push-auth-context', () => ({ + readPushAuthContext: async (_store: unknown, slot: number) => LOGINS[slot] ?? (slot === 0 ? remembered.value : null), +})); vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn(), @@ -88,6 +93,22 @@ async function callRoute(accountId: string, login?: string) { const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap')); +describe('push preview after the browser was closed', () => { + it('reads the login from the remembered session when the context cookie is gone', async () => { + remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' }; + LOGINS.length = 0; + try { + SESSIONS = { 'Basic remembered': session('https://one.example', 'a') }; + const { status, body } = await callRoute('a'); + expect(status).toBe(200); + expect((body.email as { subject: string }).subject).toBe('mail of Basic remembered'); + } finally { + remembered.value = null; + LOGINS.push(...ORIGINAL_LOGINS); + } + }); +}); + describe('push preview when the account id is in more than one login', () => { beforeEach(() => { fetchJmapServer.mockClear(); diff --git a/lib/stalwart/push-auth-context.ts b/lib/stalwart/push-auth-context.ts new file mode 100644 index 000000000..793589101 --- /dev/null +++ b/lib/stalwart/push-auth-context.ts @@ -0,0 +1,31 @@ +import { cookies } from 'next/headers'; +import { decryptSession, sessionAuthHeader } from '@/lib/auth/crypto'; +import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor } from '@/lib/auth/session-age'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readImpersonationConfig } from '@/lib/impersonation/master-config'; +import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; + +type CookieStore = Awaited>; + +/** + * The login for a request from the service worker, which has no page to refresh + * anything. The context cookie goes with the browser session, so a browser that + * was closed and woken again by a push only has the remembered one, which a page + * would turn back into a context on load (GET /api/auth/session, with the same + * checks). Not signed in with "remember me", or by OAuth with an expired token: + * none, and the worker shows its generic notification. + */ +export async function readPushAuthContext(cookieStore: CookieStore, slot: number): Promise { + const context = readStalwartAuthContextFromStore(cookieStore, slot); + if (context) return context; + const token = cookieStore.get(sessionCookieName(slot))?.value; + if (!token) return null; + const credentials = decryptSession(token); + if (!credentials) return null; + const impersonation = readImpersonationConfig(); + if (impersonation && credentials.password === impersonation.masterPassword) return null; + const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); + const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]); + if (isPastMaxAge(signedInAt, maxHours)) return null; + return { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials) }; +}