fix: pin resolved IP for stored custom JMAP endpoints and branding URLs

This commit is contained in:
Linus Rath
2026-08-26 23:02:19 +02:00
parent 2f1192bb32
commit 19756e4337
13 changed files with 463 additions and 66 deletions
+9 -3
View File
@@ -2,6 +2,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';
import { DisallowedUrlError } from '@/lib/security/url-guard';
/**
* POST /api/account/stalwart/jmap
@@ -24,17 +25,18 @@ export async function POST(request: NextRequest) {
const body = await request.text();
const fetchOptions = { trusted: creds.trusted };
const directUrl = `${creds.serverUrl}/jmap/`;
let response = await postJmap(directUrl, creds.authHeader, body);
let response = await postJmap(directUrl, creds.authHeader, body, fetchOptions);
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 session = await fetchJmapSession(creds.serverUrl, creds.authHeader, fetchOptions);
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
if (apiUrl && apiUrl !== directUrl) {
response = await postJmap(apiUrl, creds.authHeader, body);
response = await postJmap(apiUrl, creds.authHeader, body, fetchOptions);
}
}
@@ -55,6 +57,10 @@ export async function POST(request: NextRequest) {
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
return NextResponse.json({ error: error.message }, { status: 502 });
}
if (error instanceof DisallowedUrlError) {
logger.warn('Stalwart JMAP passthrough refused non-public server address', { error: error.message });
return NextResponse.json({ error: 'JMAP server address is not allowed' }, { status: 502 });
}
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
+21 -18
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { configManager } from '@/lib/admin/config-manager';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
@@ -20,26 +21,31 @@ interface JmapMethodResponse {
methodResponses?: Array<[string, Record<string, unknown>, string]>;
}
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
type ServerCreds = Pick<StalwartCredentials, 'serverUrl' | 'authHeader' | 'trusted'>;
async function fetchWithTimeout(
url: string,
init: Parameters<typeof fetch>[1],
trusted: boolean,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
return await fetchJmapServer(url, { ...init, signal: controller.signal }, trusted);
} finally {
clearTimeout(timer);
}
}
async function jmapCall(
serverUrl: string,
authHeader: string,
creds: ServerCreds,
body: JmapMethodCall,
): Promise<JmapMethodResponse> {
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
const res = await fetchWithTimeout(`${creds.serverUrl}/jmap/`, {
method: 'POST',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
headers: { 'Authorization': creds.authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}, creds.trusted);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
@@ -47,14 +53,11 @@ async function jmapCall(
return res.json() as Promise<JmapMethodResponse>;
}
async function getStalwartAccountId(
serverUrl: string,
authHeader: string,
): Promise<string | null> {
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
async function getStalwartAccountId(creds: ServerCreds): Promise<string | null> {
const res = await fetchWithTimeout(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': authHeader },
});
headers: { 'Authorization': creds.authHeader },
}, creds.trusted);
if (!res.ok) return null;
const session = await res.json() as { primaryAccounts?: Record<string, string> };
return session.primaryAccounts?.['urn:stalwart:jmap']
@@ -119,7 +122,7 @@ export async function POST(request: NextRequest) {
}
const oauthOnly = body.oauthOnly === true;
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
const accountId = await getStalwartAccountId(creds);
if (!accountId) {
return NextResponse.json(
{ error: 'Could not resolve Stalwart account from JMAP session.' },
@@ -127,7 +130,7 @@ export async function POST(request: NextRequest) {
);
}
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
const queryRes = await jmapCall(creds, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [[
'x:OAuthClient/query',
@@ -179,7 +182,7 @@ export async function POST(request: NextRequest) {
};
}
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
const setRes = await jmapCall(creds, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
});
+10 -4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
interface DiscoveryAccountRequest {
key: string;
@@ -16,9 +17,14 @@ function buildPublicUrl(serverUrl: string, path: string): string {
return new URL(path, serverUrl).toString();
}
async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise<string | null> {
async function probeCalendarHome(
serverUrl: string,
authHeader: string,
accountName: string,
trusted: boolean,
): Promise<string | null> {
const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`);
const response = await fetch(targetUrl, {
const response = await fetchJmapServer(targetUrl, {
method: 'PROPFIND',
headers: {
Authorization: authHeader,
@@ -33,7 +39,7 @@ async function probeCalendarHome(serverUrl: string, authHeader: string, accountN
</D:prop>
</D:propfind>`,
redirect: 'manual',
});
}, trusted);
if (response.status === 207) {
return targetUrl;
@@ -75,7 +81,7 @@ export async function POST(request: NextRequest) {
for (const candidate of candidates) {
try {
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate);
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate, creds.trusted);
if (url) {
resolvedAccount = candidate;
break;
+6 -4
View File
@@ -121,7 +121,8 @@ export async function POST(request: NextRequest) {
// configured public hostname, which the host process may not be able to
// resolve (the browser client rewrites those URLs back to the origin for
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
const fetchOptions = { trusted: creds.trusted };
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, fetchOptions);
if (!session) {
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
}
@@ -171,7 +172,7 @@ export async function POST(request: NextRequest) {
],
};
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions);
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (!queryResp) {
const err = findResponse(queryRes, 'error', '0');
@@ -207,7 +208,7 @@ export async function POST(request: NextRequest) {
methodCalls: [
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
],
});
}, fetchOptions);
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
}
@@ -272,8 +273,9 @@ async function jmapPost(
apiUrl: string,
authHeader: string,
payload: unknown,
options: { trusted?: boolean } = {},
): Promise<unknown> {
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload), options);
if (!res.ok) {
throw new Error(`JMAP request failed (${res.status})`);
}
+20 -11
View File
@@ -7,6 +7,8 @@ import {
getStalwartCredentials,
type StalwartCredentials,
} from '@/lib/stalwart/credentials';
import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { DisallowedUrlError } from '@/lib/security/url-guard';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -15,6 +17,8 @@ interface ResolvedTarget {
authHeader: string;
apiUrl: string;
accountId: string;
/** See StalwartCredentials.trusted - false routes through the guarded fetch. */
trusted: boolean;
}
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
@@ -31,9 +35,10 @@ async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarge
probes.push(
(async () => {
try {
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
const trusted = await isTrustedJmapServerUrl(serverUrl);
const res = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: ctx.authHeader },
});
}, trusted);
if (!res.ok) return null;
const session = (await res.json()) as {
apiUrl?: string;
@@ -42,7 +47,7 @@ async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarge
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!session.apiUrl || !mailAccountId) return null;
if (mailAccountId !== accountId) return null;
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId, trusted };
} catch {
return null;
}
@@ -54,9 +59,9 @@ async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarge
}
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
const sessionRes = await fetchJmapServer(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
}, creds.trusted);
if (!sessionRes.ok) return null;
const session = (await sessionRes.json()) as {
apiUrl?: string;
@@ -65,7 +70,7 @@ async function resolveDefaultTarget(creds: StalwartCredentials): Promise<Resolve
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) return null;
return { authHeader: creds.authHeader, apiUrl, accountId };
return { authHeader: creds.authHeader, apiUrl, accountId, trusted: creds.trusted };
}
/**
@@ -113,9 +118,9 @@ export async function GET(request: NextRequest) {
authHeader = creds.authHeader;
}
const { apiUrl, accountId } = target;
const { apiUrl, accountId, trusted } = target;
const inboxRes = await fetch(apiUrl, {
const inboxRes = await fetchJmapServer(apiUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
@@ -131,7 +136,7 @@ export async function GET(request: NextRequest) {
],
],
}),
});
}, trusted);
if (!inboxRes.ok) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
@@ -205,14 +210,14 @@ export async function GET(request: NextRequest) {
methodCalls,
};
const jmapRes = await fetch(apiUrl, {
const jmapRes = await fetchJmapServer(apiUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
}, trusted);
if (!jmapRes.ok) {
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
}
@@ -264,6 +269,10 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
if (error instanceof DisallowedUrlError) {
logger.warn('push preview refused non-public server address', { error: error.message });
return NextResponse.json({ error: 'JMAP server address is not allowed' }, { status: 502 });
}
// `fetch failed` from undici is too generic to debug - the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
+35 -16
View File
@@ -6,6 +6,39 @@ export const dynamic = 'force-dynamic';
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
const FETCH_TIMEOUT_MS = 5000;
const MAX_REDIRECTS = 3;
/**
* Follow redirects by hand and only towards the same host (path / trailing
* slash fixes) or an https upgrade of it. The wizard's server is allowed to
* be private (it usually is - docker-internal Stalwart), but a probe URL must
* not be steerable at some *other* internal host via a redirect chain.
*/
async function probe(url: string, method: 'GET' | 'HEAD'): Promise<Response> {
let current = new URL(url);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(current, { method, redirect: 'manual', signal: controller.signal });
} finally {
clearTimeout(timer);
}
if (res.status < 300 || res.status >= 400) return res;
const location = res.headers.get('location');
if (!location) return res;
const next = new URL(location, current);
const sameHost = next.host === current.host && next.protocol === current.protocol;
const httpsUpgrade = next.protocol === 'https:' && next.hostname === current.hostname;
if (!sameHost && !httpsUpgrade) {
throw new Error(`Redirected to a different host (${next.host})`);
}
await res.body?.cancel().catch(() => {});
current = next;
}
throw new Error('Too many redirects');
}
/**
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
@@ -50,14 +83,7 @@ export async function POST(request: NextRequest) {
for (const endpoint of JMAP_ENDPOINTS) {
const target = base + endpoint;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(target, {
method: 'GET',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
const res = await probe(target, 'GET');
if (!res.ok) continue;
const text = await res.text();
@@ -76,14 +102,7 @@ export async function POST(request: NextRequest) {
// No JMAP session found. Was the server even reachable?
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(base, {
method: 'HEAD',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
const res = await probe(base, 'HEAD');
return NextResponse.json({
status: 'reachable_no_jmap',
httpStatus: res.status,
+10 -2
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { DisallowedUrlError } from '@/lib/security/url-guard';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'MKCALENDAR', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
@@ -100,14 +102,16 @@ export async function POST(request: NextRequest) {
body = request.body;
}
const response = await fetch(targetUrl, {
// A user-chosen server (allowCustomJmapEndpoint) goes through the
// rebinding-safe fetch; see lib/stalwart/server-fetch.ts.
const response = await fetchJmapServer(targetUrl, {
method,
headers: upstreamHeaders,
body,
redirect: 'follow',
// `duplex: 'half'` is required by undici when sending a streaming request body.
...(method === 'PUT' ? { duplex: 'half' } : {}),
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
}, creds.trusted);
// For file downloads (GET), stream the response back
if (method === 'GET') {
@@ -144,6 +148,10 @@ export async function POST(request: NextRequest) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof DisallowedUrlError) {
logger.warn('WebDAV proxy refused non-public server address', { error: error.message });
return NextResponse.json({ error: 'JMAP server address is not allowed' }, { status: 502 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
vi.mock('@/lib/admin/paths', () => ({ getConfigDir: () => '/config' }));
vi.mock('@/lib/security/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/security/url-guard')>();
return { ...actual, fetchPublicUrl: vi.fn() };
});
import { fetchPublicUrl, DisallowedUrlError } from '@/lib/security/url-guard';
import { fetchBrandingAsset } from '@/lib/admin/branding-asset';
const guardedFetch = fetchPublicUrl as unknown as Mock;
beforeEach(() => {
guardedFetch.mockReset();
});
describe('fetchBrandingAsset (remote URLs)', () => {
it('fetches remote assets through the rebinding-safe fetch', async () => {
guardedFetch.mockResolvedValueOnce(new Response('PNG', { status: 200 }));
const buf = await fetchBrandingAsset('https://cdn.example.com/logo.png', 'logo');
expect(buf.toString()).toBe('PNG');
expect(guardedFetch.mock.calls[0][0]).toBe('https://cdn.example.com/logo.png');
});
it('re-validates every redirect hop', async () => {
guardedFetch
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: 'https://cdn2.example.com/logo.png' } }))
.mockResolvedValueOnce(new Response('PNG2', { status: 200 }));
const buf = await fetchBrandingAsset('https://cdn.example.com/logo.png', 'logo');
expect(buf.toString()).toBe('PNG2');
expect(guardedFetch.mock.calls[1][0]).toBe('https://cdn2.example.com/logo.png');
});
it('surfaces a blocked address as an error', async () => {
guardedFetch.mockRejectedValueOnce(new DisallowedUrlError('http://169.254.169.254/'));
await expect(fetchBrandingAsset('http://169.254.169.254/', 'logo')).rejects.toBeInstanceOf(DisallowedUrlError);
});
it('reports non-2xx upstream status', async () => {
guardedFetch.mockResolvedValueOnce(new Response(null, { status: 404 }));
await expect(fetchBrandingAsset('https://cdn.example.com/missing.png', 'logo')).rejects.toThrow('Failed to fetch logo: 404');
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn(),
},
}));
vi.mock('@/lib/security/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/security/url-guard')>();
return {
...actual,
fetchPublicUrl: vi.fn(),
};
});
import { configManager } from '@/lib/admin/config-manager';
import { fetchPublicUrl } from '@/lib/security/url-guard';
import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
const mockGet = configManager.get as unknown as Mock;
const guardedFetch = fetchPublicUrl as unknown as Mock;
let plainFetch: Mock;
function configure(values: Record<string, unknown>) {
mockGet.mockImplementation((key: string, fallback?: unknown) => (key in values ? values[key] : fallback));
}
function response(status: number, headers: Record<string, string> = {}, body: string | null = null) {
return new Response(status >= 300 && status < 400 ? null : body, { status, headers });
}
beforeEach(() => {
plainFetch = vi.fn(async () => response(200, {}, 'plain'));
vi.stubGlobal('fetch', plainFetch);
guardedFetch.mockReset();
mockGet.mockReset();
configure({});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('isTrustedJmapServerUrl', () => {
it('trusts the global jmapServerUrl regardless of trailing slash or host case', async () => {
configure({ jmapServerUrl: 'https://Mail.Example.com/' });
expect(await isTrustedJmapServerUrl('https://mail.example.com')).toBe(true);
});
it('trusts entries of the jmapServers list', async () => {
configure({
jmapServers: [{ id: 'corp', label: 'Corp', url: 'http://10.0.0.5:8080', domains: ['corp.example'] }],
});
expect(await isTrustedJmapServerUrl('http://10.0.0.5:8080')).toBe(true);
});
it('does not trust a URL that matches no configured server', async () => {
configure({ jmapServerUrl: 'https://mail.example.com' });
expect(await isTrustedJmapServerUrl('https://evil.example')).toBe(false);
});
});
describe('fetchJmapServer', () => {
it('uses the plain fetch for trusted servers', async () => {
const res = await fetchJmapServer('http://10.0.0.5/jmap/', { method: 'POST', body: '{}' }, true);
expect(res.status).toBe(200);
expect(plainFetch).toHaveBeenCalledTimes(1);
expect(guardedFetch).not.toHaveBeenCalled();
});
it('routes untrusted servers through fetchPublicUrl', async () => {
guardedFetch.mockResolvedValueOnce(response(200, {}, 'guarded'));
const res = await fetchJmapServer(
'https://custom.example/jmap/',
{ method: 'POST', body: '{}', headers: { Authorization: 'Basic abc' } },
false,
);
expect(await res.text()).toBe('guarded');
expect(plainFetch).not.toHaveBeenCalled();
const [url, init] = guardedFetch.mock.calls[0];
expect(url).toBe('https://custom.example/jmap/');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({ authorization: 'Basic abc' });
});
it('returns the 3xx untouched when redirect is manual', async () => {
guardedFetch.mockResolvedValueOnce(response(301, { location: 'https://custom.example/jmap' }));
const res = await fetchJmapServer('https://custom.example/jmap/', { redirect: 'manual' }, false);
expect(res.status).toBe(301);
expect(guardedFetch).toHaveBeenCalledTimes(1);
});
it('follows same-origin redirects, keeping Authorization', async () => {
guardedFetch
.mockResolvedValueOnce(response(308, { location: '/jmap/' }))
.mockResolvedValueOnce(response(200, {}, 'ok'));
const res = await fetchJmapServer(
'https://custom.example/jmap',
{ method: 'POST', body: '{}', headers: { Authorization: 'Basic abc' }, redirect: 'follow' },
false,
);
expect(res.status).toBe(200);
const [url, init] = guardedFetch.mock.calls[1];
expect(url).toBe('https://custom.example/jmap/');
expect(init.method).toBe('POST');
expect(init.headers.authorization).toBe('Basic abc');
});
it('drops Authorization on a cross-origin redirect and downgrades 302 POST to GET', async () => {
guardedFetch
.mockResolvedValueOnce(response(302, { location: 'https://other.example/x' }))
.mockResolvedValueOnce(response(200, {}, 'ok'));
await fetchJmapServer(
'https://custom.example/jmap/',
{ method: 'POST', body: '{}', headers: { Authorization: 'Basic abc', 'Content-Type': 'application/json' } },
false,
);
const [url, init] = guardedFetch.mock.calls[1];
expect(url).toBe('https://other.example/x');
expect(init.method).toBe('GET');
expect(init.body).toBeUndefined();
expect(init.headers.authorization).toBeUndefined();
expect(init.headers['content-type']).toBeUndefined();
});
it('gives up after too many redirects', async () => {
guardedFetch.mockResolvedValue(response(302, { location: '/loop' }));
await expect(fetchJmapServer('https://custom.example/loop', {}, false)).rejects.toThrow(/Too many redirects/);
});
it('propagates DisallowedUrlError from the guard', async () => {
const { DisallowedUrlError } = await import('@/lib/security/url-guard');
guardedFetch.mockRejectedValueOnce(new DisallowedUrlError('http://custom.example/'));
await expect(fetchJmapServer('http://custom.example/', {}, false)).rejects.toBeInstanceOf(DisallowedUrlError);
});
});
+32 -3
View File
@@ -9,10 +9,41 @@
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { getConfigDir } from '@/lib/admin/paths';
import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
/** Admin-uploaded assets are served from here but stored under getConfigDir()/branding/. */
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
const REMOTE_TIMEOUT_MS = 10_000;
const MAX_REDIRECTS = 5;
/**
* Remote branding URLs come from admin config, but they are still fetched by
* this process and rendered by public routes, so they go through the
* rebinding-safe fetch: only public addresses, every redirect hop
* re-validated. Private-network logos should be uploaded via the admin
* branding upload instead of linked.
*/
async function fetchRemoteAsset(url: string, label: string): Promise<Buffer> {
let currentUrl = url;
let res: PublicFetchResponse | undefined;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
res = await fetchPublicUrl(currentUrl, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
if (!location) break;
await res.body?.cancel().catch(() => {});
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
if (!res || !res.ok) {
throw new Error(`Failed to fetch ${label}: ${res?.status ?? 'too many redirects'}`);
}
return Buffer.from(await res.arrayBuffer());
}
/**
* Read the bytes behind a branding URL: a remote http(s) URL, an
* admin-uploaded asset, or a path relative to public/.
@@ -22,9 +53,7 @@ const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
export async function fetchBrandingAsset(url: string, label = 'branding asset'): Promise<Buffer> {
// Absolute URL (http/https)
if (url.startsWith('http://') || url.startsWith('https://')) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${label}: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
return fetchRemoteAsset(url, label);
}
if (url.startsWith(ADMIN_BRANDING_PREFIX)) {
+11 -1
View File
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
export interface StalwartCredentials {
@@ -11,6 +12,13 @@ export interface StalwartCredentials {
username: string;
hasSessionCookie: boolean;
slot: number;
/**
* Whether `serverUrl` matches an admin-configured server. A `false` here
* means the user picked the URL themselves (`allowCustomJmapEndpoint`), so
* server-side requests to it must use the rebinding-safe fetch - see
* `fetchJmapServer` in `@/lib/stalwart/server-fetch`.
*/
trusted: boolean;
}
function parseSlot(raw: string | null): number | null {
@@ -35,12 +43,14 @@ export async function getStalwartCredentials(request: NextRequest): Promise<Stal
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) continue;
const serverUrl = context.serverUrl.replace(/\/+$/, '');
return {
serverUrl: context.serverUrl.replace(/\/+$/, ''),
serverUrl,
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
slot,
trusted: await isTrustedJmapServerUrl(serverUrl),
};
}
+16 -4
View File
@@ -17,8 +17,18 @@
* this process — see calendar-agenda's session handling).
*/
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
const MAX_REDIRECTS = 3;
/**
* `trusted` mirrors `StalwartCredentials.trusted`: pass `false` for a
* user-chosen server so the request goes through the rebinding-safe fetch.
*/
export interface JmapFetchOptions {
trusted?: boolean;
}
export interface JmapSessionDocument {
apiUrl?: string;
capabilities?: Record<string, unknown>;
@@ -51,10 +61,11 @@ export async function postJmap(
apiUrl: string,
authHeader: string,
body: string,
options: JmapFetchOptions = {},
): Promise<Response> {
let url = new URL(apiUrl);
for (let attempt = 0; attempt <= MAX_REDIRECTS; attempt++) {
const response = await fetch(url, {
const response = await fetchJmapServer(url.toString(), {
method: 'POST',
headers: {
'Authorization': authHeader,
@@ -62,7 +73,7 @@ export async function postJmap(
},
body,
redirect: 'manual',
});
}, options.trusted);
if (response.status < 300 || response.status >= 400) {
return response;
@@ -91,15 +102,16 @@ export async function postJmap(
export async function fetchJmapSession(
serverUrl: string,
authHeader: string,
options: JmapFetchOptions = {},
): Promise<JmapSessionDocument | null> {
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
for (const url of candidates) {
try {
const res = await fetch(url, {
const res = await fetchJmapServer(url, {
method: 'GET',
headers: { Authorization: authHeader },
redirect: 'follow',
});
}, options.trusted);
if (!res.ok) continue;
const session = (await res.json()) as JmapSessionDocument;
if (session && typeof session === 'object' && session.primaryAccounts) {
+111
View File
@@ -0,0 +1,111 @@
/**
* Server-side fetch for the JMAP server stored in a user's auth-context
* cookie.
*
* The stored `serverUrl` is trusted when it matches an admin-configured server
* (`jmapServerUrl` / `jmapServers`); such servers may legitimately live on a
* private network, so they get the plain `fetch`. When `allowCustomJmapEndpoint`
* is on, the URL was chosen by the user at login. It was pre-checked and the
* login verification was pinned, but a DNS name can be re-pointed at an
* internal address *after* login, so every later request to it must go through
* the rebinding-safe `fetchPublicUrl` (GHSA-24w9-8r42-8jwm, follow-up).
*
* Trust is derived from the current config on every read rather than persisted
* in the cookie: existing sessions keep working after an upgrade, and removing
* a server from the config immediately drops its sessions to the guarded path.
*/
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
const MAX_REDIRECTS = 5;
export async function isTrustedJmapServerUrl(serverUrl: string): Promise<boolean> {
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
return resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList) !== null;
}
export type ServerFetchInit = RequestInit & { duplex?: 'half' };
function headersToRecord(init: HeadersInit | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!init) return out;
const iterable = init instanceof Headers ? init : new Headers(init);
iterable.forEach((value, key) => { out[key] = value; });
return out;
}
/**
* Fetch `url` on behalf of a stored JMAP server.
*
* `trusted === false` routes through {@link fetchPublicUrl}; anything else
* (including `undefined`, which only test doubles produce - real credentials
* always carry an explicit boolean) uses the plain `fetch`.
*
* On the guarded path redirects are followed by hand so that every hop is
* re-validated: `Authorization` is dropped when the origin changes, and a
* 303 (or 301/302 on a POST) downgrades to GET like `fetch` would. Pass
* `redirect: 'manual'` to get the 3xx response back instead.
*/
export async function fetchJmapServer(
url: string,
init: ServerFetchInit = {},
trusted: boolean | undefined,
): Promise<Response> {
if (trusted !== false) {
return fetch(url, init);
}
const { redirect, headers: initHeaders, ...rest } = init;
const headers = headersToRecord(initHeaders);
let currentUrl = url;
let method = (rest.method ?? 'GET').toUpperCase();
let body = rest.body;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response: PublicFetchResponse = await fetchPublicUrl(currentUrl, {
...(rest as Omit<ServerFetchInit, 'redirect' | 'headers'>),
method,
body: body as never,
headers,
});
if (redirect === 'manual' || response.status < 300 || response.status >= 400) {
return response as unknown as Response;
}
const location = response.headers.get('location');
if (!location) {
return response as unknown as Response;
}
await response.body?.cancel().catch(() => {});
const next = new URL(location, currentUrl);
if (next.origin !== new URL(currentUrl).origin) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'authorization' || key.toLowerCase() === 'cookie') {
delete headers[key];
}
}
}
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
method = 'GET';
body = undefined;
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'content-type' || key.toLowerCase() === 'content-length') {
delete headers[key];
}
}
}
currentUrl = next.toString();
}
throw new Error(`Too many redirects fetching ${url}`);
}