diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx index 4387ef2e..5bda49b3 100644 --- a/app/(main)/admin/_tabs/auth.tsx +++ b/app/(main)/admin/_tabs/auth.tsx @@ -273,6 +273,7 @@ export function AuthTab() { + diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index fd0e861f..9edfbd31 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -3,9 +3,8 @@ import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptPayload } from '@/lib/auth/crypto'; import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server'; -import { getRequiredConfig } from '@/lib/oauth/token-exchange'; +import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; -import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { getOauthScopes } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { hasSessionSecret } from '@/lib/auth/session-secret'; @@ -62,7 +61,7 @@ export async function POST(request: NextRequest) { } const { clientId, discoveryUrl } = getRequiredConfig(serverId); - const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl }); + const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() }); if (!metadata?.authorization_endpoint) { return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index e19bc781..11fa1a6b 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; +import { getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { readFileEnv } from '@/lib/read-file-env'; @@ -52,9 +53,13 @@ async function tryTokenRequest( } } -async function findTokenEndpoint(serverUrl: string): Promise { +async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise { + // Admin-trusted callers (matched server entry or configured JMAP server URL) + // honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always + // go through the SSRF validator regardless of the setting. + const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl; // 1. Try OAuth discovery - const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl }); + const metadata = await discoverOAuth(serverUrl, { validateEndpoint }); if (metadata?.token_endpoint) return metadata.token_endpoint; // 2. Try common Stalwart token endpoint paths directly @@ -105,14 +110,17 @@ export async function POST(request: NextRequest) { let upstreamUrl: string; let resolvedServerId: string | null = null; + let adminTrusted = false; const requestedEntry = findServerById(serverList, requestedServerId); const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl); if (matchedEntry) { upstreamUrl = matchedEntry.url; resolvedServerId = matchedEntry.id; + adminTrusted = true; } else if (configuredServerUrl) { upstreamUrl = configuredServerUrl; + adminTrusted = true; } else if (allowCustomEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) { logger.warn('TOTP token exchange: rejected non-public server URL'); @@ -123,7 +131,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 }); } - const tokenEndpoint = await findTokenEndpoint(upstreamUrl); + const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted); if (!tokenEndpoint) { logger.warn('TOTP token exchange: no token endpoint found'); return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); diff --git a/lib/__tests__/oauth-discovery.test.ts b/lib/__tests__/oauth-discovery.test.ts index 609a9294..5b8228af 100644 --- a/lib/__tests__/oauth-discovery.test.ts +++ b/lib/__tests__/oauth-discovery.test.ts @@ -146,6 +146,23 @@ describe('oauth/discovery', () => { expect(consoleSpy).toHaveBeenCalled(); }); + it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => { + // Split-DNS deployments: mail.example.com resolves to an RFC-1918 address + // locally. With the SSRF validator off, discovery must succeed. + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + issuer: 'https://mail.example.com', + authorization_endpoint: 'http://10.0.0.5/authorize', + token_endpoint: 'http://10.0.0.5/token', + }), + })); + + const result = await discoverOAuth('https://mail.example.com'); + + expect(result?.token_endpoint).toBe('http://10.0.0.5/token'); + }); + it('caches results - second call for same server URL does not re-fetch', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: true, diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 320c783b..57e8535b 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record('oauthAllowPrivateEndpoints', false); + return allowPrivate ? undefined : isPublicHttpUrl; +} + function getGlobalClientSecret(): string { const adminSecret = configManager.get('oauthClientSecret', ''); if (adminSecret) return adminSecret; @@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string { export async function getTokenEndpoint(serverId?: string | null): Promise { const { discoveryUrl } = getRequiredConfig(serverId); - const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl }); + const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() }); if (!metadata?.token_endpoint) { throw new Error('OAuth token endpoint not found'); } @@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise { const { discoveryUrl } = getRequiredConfig(serverId); - return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl }); + return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() }); } export function buildOAuthParams(base: Record, serverId?: string | null): URLSearchParams {