diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 0fbd07f7..2ec20847 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -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('jmapServerUrl', '') || + process.env.JMAP_SERVER_URL || + process.env.NEXT_PUBLIC_JMAP_SERVER_URL || + ''; + const allowCustomEndpoint = configManager.get('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); diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 138cacc2..7220673e 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -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('jmapServerUrl', '') || + process.env.JMAP_SERVER_URL || + process.env.NEXT_PUBLIC_JMAP_SERVER_URL || + ''; + const allowCustomEndpoint = configManager.get('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, diff --git a/lib/__tests__/verify-jmap-auth.test.ts b/lib/__tests__/verify-jmap-auth.test.ts index 1ccce302..efc8dfe8 100644 --- a/lib/__tests__/verify-jmap-auth.test.ts +++ b/lib/__tests__/verify-jmap-auth.test.ts @@ -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(); + }); }); diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index ba421e0c..1d2d1616 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -40,11 +40,15 @@ export function validateProxyAuthHeader(authHeader: string): void { } } -export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise { +export async function verifyJmapAuth( + serverUrl: string, + authHeader: string, + options: { trusted?: boolean } = {}, +): Promise { 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); }