fix: pin JMAP auth verification to configured server URL #237
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
@@ -38,10 +39,37 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
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 slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
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 token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
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';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
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 });
|
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 slot = getSlot(request, bodySlot);
|
||||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||||
|
|
||||||
await setStalwartAuthContext(slot, {
|
await setStalwartAuthContext(slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
|
|||||||
@@ -138,4 +138,38 @@ describe('verifyJmapAuth SSRF protection', () => {
|
|||||||
});
|
});
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||||
validateProxyAuthHeader(authHeader);
|
validateProxyAuthHeader(authHeader);
|
||||||
|
|
||||||
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
|
if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
|
||||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
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;
|
let response: Response | undefined;
|
||||||
|
|
||||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
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);
|
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user