fix: preserve POST across redirects in Stalwart JMAP passthrough #627
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user