fix: pin JMAP auth verification to configured server URL #237

This commit is contained in:
Linus Rath
2026-04-30 15:34:14 +02:00
parent 65eef4b2b8
commit 45a4db1c22
4 changed files with 100 additions and 5 deletions
+29 -1
View File
@@ -10,6 +10,7 @@ import {
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = {
@@ -38,10 +39,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
+30 -1
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number {
@@ -24,8 +26,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
+34
View File
@@ -138,4 +138,38 @@ describe('verifyJmapAuth SSRF protection', () => {
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('with trusted=true, accepts a hostname resolving to a private IP', async () => {
lookup.mockResolvedValue([{ address: '10.0.20.5', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ apiUrl: 'https://mail.internal/api', accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('https://mail.internal', 'Bearer x', { trusted: true }),
).resolves.toBe('https://mail.internal');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.internal/.well-known/jmap',
expect.objectContaining({ redirect: 'manual' }),
);
});
it('with trusted=true, still rejects unsupported protocols', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('file:///etc/passwd', 'Bearer x', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('with trusted=true, still rejects an invalid Authorization header', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('https://mail.internal', 'NotAuth', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+7 -3
View File
@@ -40,11 +40,15 @@ export function validateProxyAuthHeader(authHeader: string): void {
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
export async function verifyJmapAuth(
serverUrl: string,
authHeader: string,
options: { trusted?: boolean } = {},
): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
@@ -56,7 +60,7 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isPublicHttpUrl(currentUrl))) {
if (!options.trusted && !(await isPublicHttpUrl(currentUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}