fix: preserve POST across redirects in Stalwart JMAP passthrough #627

This commit is contained in:
Linus Rath
2026-07-16 22:51:07 +02:00
parent 4a4950c3e5
commit f749ee1f2a
4 changed files with 298 additions and 51 deletions
+25 -8
View File
@@ -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',
});
+5 -43
View File
@@ -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<string, string>;
capabilities?: Record<string, unknown>;
}
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<JmapSession | null> {
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<unknown> {
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})`);
}
+136
View File
@@ -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<string, string> = {}): 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();
});
});
+132
View File
@@ -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<string, unknown>;
primaryAccounts?: Record<string, string>;
accounts?: Record<string, unknown>;
}
/**
* 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<Response> {
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<JmapSessionDocument | null> {
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;
}
}