From b9fe961ae851544c9edd26069dc3c2fd19c2fbe8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:42:29 +0200 Subject: [PATCH] fix: session cookie not stored for relative JMAP server URLs (dev mock logouts) --- lib/__tests__/verify-jmap-auth.test.ts | 28 ++++++++++++++++++++++++++ lib/auth/verify-jmap-auth.ts | 9 +++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/__tests__/verify-jmap-auth.test.ts b/lib/__tests__/verify-jmap-auth.test.ts index efc8dfe8e..665bb27cd 100644 --- a/lib/__tests__/verify-jmap-auth.test.ts +++ b/lib/__tests__/verify-jmap-auth.test.ts @@ -173,3 +173,31 @@ describe('verifyJmapAuth SSRF protection', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe('normalizeJmapServerUrl', () => { + async function load() { + return import('@/lib/auth/verify-jmap-auth'); + } + + it('accepts an app-relative URL (dev mock JMAP server)', async () => { + const { normalizeJmapServerUrl } = await load(); + expect(normalizeJmapServerUrl('/api/dev-jmap')).toBe('/api/dev-jmap'); + }); + + it('strips query, hash, and trailing slashes from an app-relative URL', async () => { + const { normalizeJmapServerUrl } = await load(); + expect(normalizeJmapServerUrl('/api/dev-jmap/?x=1#frag')).toBe('/api/dev-jmap'); + }); + + it('rejects protocol-relative URLs', async () => { + const { normalizeJmapServerUrl } = await load(); + expect(() => normalizeJmapServerUrl('//evil.example.com/api')).toThrow('Invalid server URL'); + }); + + it('normalizes absolute URLs unchanged', async () => { + const { normalizeJmapServerUrl } = await load(); + expect(normalizeJmapServerUrl('https://mail.example.com/jmap/?a=b')).toBe( + 'https://mail.example.com/jmap', + ); + }); +}); diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 06a557081..f7391c355 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -18,6 +18,15 @@ function isSupportedProtocol(protocol: string): boolean { } export function normalizeJmapServerUrl(serverUrl: string): string { + // App-relative URLs (the dev mock JMAP server, e.g. /api/dev-jmap) have no + // host or protocol to validate; they can only ever target this app itself. + // Protocol-relative URLs (//host/...) are NOT app-relative and fall through + // to the absolute-URL parse below, which rejects them. + if (serverUrl.startsWith('/') && !serverUrl.startsWith('//')) { + const url = new URL(serverUrl, 'http://relative.invalid'); + return url.pathname.replace(/\/+$/, ''); + } + let url: URL; try { url = new URL(serverUrl);