From f749ee1f2aa770352b507ee454f4b500c54aea31 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:51:07 +0200 Subject: [PATCH] fix: preserve POST across redirects in Stalwart JMAP passthrough #627 --- app/api/account/stalwart/jmap/route.ts | 33 ++++-- app/api/calendar-agenda/route.ts | 48 +-------- lib/__tests__/stalwart-jmap-api.test.ts | 136 ++++++++++++++++++++++++ lib/stalwart/jmap-api.ts | 132 +++++++++++++++++++++++ 4 files changed, 298 insertions(+), 51 deletions(-) create mode 100644 lib/__tests__/stalwart-jmap-api.test.ts create mode 100644 lib/stalwart/jmap-api.ts diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts index 7f292c17..150e5e2a 100644 --- a/app/api/account/stalwart/jmap/route.ts +++ b/app/api/account/stalwart/jmap/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; /** * POST /api/account/stalwart/jmap @@ -23,14 +24,26 @@ export async function POST(request: NextRequest) { const body = await request.text(); - const response = await fetch(`${creds.serverUrl}/jmap/`, { - method: 'POST', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body, - }); + const directUrl = `${creds.serverUrl}/jmap/`; + let response = await postJmap(directUrl, creds.authHeader, body); + + if (response.status === 404) { + // `${serverUrl}/jmap/` is not the API endpoint on this deployment + // (path prefix, non-Stalwart URL layout). Resolve the session's + // advertised apiUrl on the same host and retry once. + const session = await fetchJmapSession(creds.serverUrl, creds.authHeader); + const apiUrl = rebaseApiUrl(session, creds.serverUrl); + if (apiUrl && apiUrl !== directUrl) { + response = await postJmap(apiUrl, creds.authHeader, body); + } + } + + if (!response.ok) { + logger.warn('Stalwart JMAP passthrough upstream error', { + status: response.status, + serverUrl: creds.serverUrl, + }); + } const responseText = await response.text(); return new NextResponse(responseText, { @@ -38,6 +51,10 @@ export async function POST(request: NextRequest) { headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' }, }); } catch (error) { + if (error instanceof JmapRedirectError) { + logger.error('Stalwart JMAP passthrough redirect error', { error: error.message }); + return NextResponse.json({ error: error.message }, { status: 502 }); + } logger.error('Stalwart JMAP passthrough error', { error: error instanceof Error ? error.message : 'Unknown', }); diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4f39e455..923f3564 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { parseISO } from 'date-fns'; @@ -32,12 +33,6 @@ const EVENT_PROPERTIES = [ 'recurrenceOverrides', 'excludedRecurrenceRule', ] as const; -interface JmapSession { - apiUrl?: string; - primaryAccounts?: Record; - capabilities?: Record; -} - interface AgendaEvent { id: string; uid: string | null; @@ -141,9 +136,9 @@ export async function POST(request: NextRequest) { using.push('urn:ietf:params:jmap:principals:owner'); } - // Send method calls to the same-origin JMAP endpoint the app's passthrough - // uses — never to session.apiUrl's (possibly unreachable) public host. - const apiUrl = `${creds.serverUrl}/jmap/`; + // Send method calls to the session's apiUrl rebased onto serverUrl's host + // — never to session.apiUrl's (possibly unreachable) public host. + const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`; const now = new Date(); const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); @@ -273,45 +268,12 @@ function clampInt(value: unknown, min: number, max: number, fallback: number): n return Math.min(max, Math.max(min, Math.round(n))); } -/** - * Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's - * canonical /jmap/session first (no redirect), then /.well-known/jmap as a - * fallback for other servers. Returns null if neither yields a usable session. - */ -async function fetchJmapSession( - serverUrl: string, - authHeader: string, -): Promise { - const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`]; - for (const url of candidates) { - try { - const res = await fetch(url, { - method: 'GET', - headers: { Authorization: authHeader }, - redirect: 'follow', - }); - if (!res.ok) continue; - const session = (await res.json()) as JmapSession; - if (session && typeof session === 'object' && session.primaryAccounts) { - return session; - } - } catch { - // Try the next candidate (e.g. canonical path 404s on a non-Stalwart server). - } - } - return null; -} - async function jmapPost( apiUrl: string, authHeader: string, payload: unknown, ): Promise { - const res = await fetch(apiUrl, { - method: 'POST', - headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); + const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload)); if (!res.ok) { throw new Error(`JMAP request failed (${res.status})`); } diff --git a/lib/__tests__/stalwart-jmap-api.test.ts b/lib/__tests__/stalwart-jmap-api.test.ts new file mode 100644 index 00000000..385b7375 --- /dev/null +++ b/lib/__tests__/stalwart-jmap-api.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { postJmap, rebaseApiUrl, fetchJmapSession, JmapRedirectError } from '@/lib/stalwart/jmap-api'; + +const realFetch = global.fetch; +const mockedFetch = vi.fn(); + +beforeEach(() => { + mockedFetch.mockReset(); + global.fetch = mockedFetch as unknown as typeof fetch; +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +function response(status: number, body = '{}', headers: Record = {}): Response { + return new Response(status >= 300 && status < 400 ? null : body, { status, headers }); +} + +describe('postJmap', () => { + it('POSTs the body with auth header and manual redirect mode', async () => { + mockedFetch.mockResolvedValueOnce(response(200, '{"methodResponses":[]}')); + + const res = await postJmap('https://mail.example.com/jmap/', 'Basic abc', '{"using":[]}'); + + expect(res.status).toBe(200); + const [url, init] = mockedFetch.mock.calls[0]; + expect(url.toString()).toBe('https://mail.example.com/jmap/'); + expect(init.method).toBe('POST'); + expect(init.redirect).toBe('manual'); + expect(init.body).toBe('{"using":[]}'); + expect(init.headers['Authorization']).toBe('Basic abc'); + }); + + it('re-POSTs (not GETs) across an https upgrade redirect', async () => { + mockedFetch + .mockResolvedValueOnce(response(301, '', { location: 'https://mail.example.com/jmap/' })) + .mockResolvedValueOnce(response(200)); + + const res = await postJmap('http://mail.example.com/jmap/', 'Basic abc', '{}'); + + expect(res.status).toBe(200); + expect(mockedFetch).toHaveBeenCalledTimes(2); + const [url, init] = mockedFetch.mock.calls[1]; + expect(url.toString()).toBe('https://mail.example.com/jmap/'); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{}'); + }); + + it('follows same-host path redirects (trailing slash normalization)', async () => { + mockedFetch + .mockResolvedValueOnce(response(308, '', { location: '/jmap/' })) + .mockResolvedValueOnce(response(200)); + + const res = await postJmap('https://mail.example.com/jmap', 'Basic abc', '{}'); + + expect(res.status).toBe(200); + expect(mockedFetch.mock.calls[1][0].toString()).toBe('https://mail.example.com/jmap/'); + }); + + it('refuses redirects to a different host', async () => { + mockedFetch.mockResolvedValueOnce( + response(302, '', { location: 'https://evil.example.net/jmap/' }), + ); + + await expect(postJmap('https://mail.example.com/jmap/', 'Basic abc', '{}')) + .rejects.toBeInstanceOf(JmapRedirectError); + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); + + it('gives up after too many redirects', async () => { + mockedFetch.mockResolvedValue( + response(302, '', { location: 'https://mail.example.com/jmap/' }), + ); + + await expect(postJmap('https://mail.example.com/jmap/', 'Basic abc', '{}')) + .rejects.toThrow('Too many redirects'); + }); + + it('returns non-redirect error responses as-is', async () => { + mockedFetch.mockResolvedValueOnce(response(401)); + + const res = await postJmap('https://mail.example.com/jmap/', 'Basic abc', '{}'); + expect(res.status).toBe(401); + }); +}); + +describe('rebaseApiUrl', () => { + it('keeps the advertised path but swaps to the reachable origin', () => { + const session = { apiUrl: 'https://public.example.org/prefix/jmap/', primaryAccounts: {} }; + expect(rebaseApiUrl(session, 'https://internal.example.com')) + .toBe('https://internal.example.com/prefix/jmap/'); + }); + + it('resolves relative apiUrl against serverUrl', () => { + const session = { apiUrl: '/jmap/', primaryAccounts: {} }; + expect(rebaseApiUrl(session, 'https://mail.example.com')) + .toBe('https://mail.example.com/jmap/'); + }); + + it('returns null when the session has no apiUrl', () => { + expect(rebaseApiUrl({ primaryAccounts: {} }, 'https://mail.example.com')).toBeNull(); + expect(rebaseApiUrl(null, 'https://mail.example.com')).toBeNull(); + }); +}); + +describe('fetchJmapSession', () => { + it('prefers the canonical /jmap/session endpoint', async () => { + mockedFetch.mockResolvedValueOnce( + response(200, JSON.stringify({ apiUrl: '/jmap/', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' } })), + ); + + const session = await fetchJmapSession('https://mail.example.com', 'Basic abc'); + + expect(session?.apiUrl).toBe('/jmap/'); + expect(mockedFetch.mock.calls[0][0]).toBe('https://mail.example.com/jmap/session'); + }); + + it('falls back to /.well-known/jmap when the canonical path 404s', async () => { + mockedFetch + .mockResolvedValueOnce(response(404)) + .mockResolvedValueOnce( + response(200, JSON.stringify({ apiUrl: '/api/jmap/', primaryAccounts: {} })), + ); + + const session = await fetchJmapSession('https://mail.example.com', 'Basic abc'); + + expect(session?.apiUrl).toBe('/api/jmap/'); + expect(mockedFetch.mock.calls[1][0]).toBe('https://mail.example.com/.well-known/jmap'); + }); + + it('returns null when no candidate yields a session', async () => { + mockedFetch.mockResolvedValue(response(404)); + expect(await fetchJmapSession('https://mail.example.com', 'Basic abc')).toBeNull(); + }); +}); diff --git a/lib/stalwart/jmap-api.ts b/lib/stalwart/jmap-api.ts new file mode 100644 index 00000000..fe041298 --- /dev/null +++ b/lib/stalwart/jmap-api.ts @@ -0,0 +1,132 @@ +/** + * Server-side helpers for talking to a JMAP API endpoint derived from the + * stored `serverUrl`. + * + * A bare `fetch(`${serverUrl}/jmap/`)` breaks in two real deployments (#627): + * + * - A 301/302 in front of the server (Cloudflare http→https upgrade, + * hostname normalization, trailing-slash rules) makes `fetch`'s default + * redirect handling re-issue the request as a GET. Stalwart answers + * `GET /jmap/` with `404 application/problem+json`, which the passthrough + * then forwards as an opaque 404. + * - The session's `apiUrl` may live on a path other than `/jmap/`. + * + * `postJmap` follows redirects manually so POST stays POST, and callers can + * recover from a wrong path by resolving the session's `apiUrl` rebased onto + * `serverUrl`'s host (the advertised public host may not be reachable from + * this process — see calendar-agenda's session handling). + */ + +const MAX_REDIRECTS = 3; + +export interface JmapSessionDocument { + apiUrl?: string; + capabilities?: Record; + primaryAccounts?: Record; + accounts?: Record; +} + +/** + * Redirects are followed only towards the same host (path/trailing-slash + * fixes) or an https upgrade of the same hostname. Anything else would leak + * the Authorization header to a third party. + */ +function isTrustedRedirect(from: URL, to: URL): boolean { + if (to.host === from.host && to.protocol === from.protocol) return true; + return to.protocol === 'https:' && to.hostname === from.hostname; +} + +export class JmapRedirectError extends Error { + constructor(message: string) { + super(message); + this.name = 'JmapRedirectError'; + } +} + +/** + * POST a JMAP request, preserving the POST method and body across redirects + * (native `fetch` downgrades POST to GET on 301/302). + */ +export async function postJmap( + apiUrl: string, + authHeader: string, + body: string, +): Promise { + let url = new URL(apiUrl); + for (let attempt = 0; attempt <= MAX_REDIRECTS; attempt++) { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': authHeader, + 'Content-Type': 'application/json', + }, + body, + redirect: 'manual', + }); + + if (response.status < 300 || response.status >= 400) { + return response; + } + + const location = response.headers.get('location'); + if (!location) return response; + + const next = new URL(location, url); + if (!isTrustedRedirect(url, next)) { + throw new JmapRedirectError( + `JMAP endpoint redirected to an untrusted host: ${next.host}`, + ); + } + url = next; + } + throw new JmapRedirectError('Too many redirects from JMAP endpoint'); +} + +/** + * Fetch the JMAP session document from the same host as `serverUrl`. Tries + * Stalwart's canonical /jmap/session first (no redirect), then + * /.well-known/jmap as a fallback for other servers. Returns null if neither + * yields a usable session. + */ +export async function fetchJmapSession( + serverUrl: string, + authHeader: string, +): Promise { + const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`]; + for (const url of candidates) { + try { + const res = await fetch(url, { + method: 'GET', + headers: { Authorization: authHeader }, + redirect: 'follow', + }); + if (!res.ok) continue; + const session = (await res.json()) as JmapSessionDocument; + if (session && typeof session === 'object' && session.primaryAccounts) { + return session; + } + } catch { + // Try the next candidate (e.g. canonical path 404s on a non-Stalwart server). + } + } + return null; +} + +/** + * Rebase the session's advertised `apiUrl` onto `serverUrl`'s origin, so + * method calls go to the host this process can actually reach rather than + * the server's configured public hostname. + */ +export function rebaseApiUrl( + session: JmapSessionDocument | null, + serverUrl: string, +): string | null { + if (!session?.apiUrl) return null; + try { + const api = new URL(session.apiUrl, `${serverUrl}/`); + const base = new URL(serverUrl); + return new URL(api.pathname + api.search, base.origin).toString(); + } catch { + return null; + } +}