fix: add OAUTH_ALLOW_PRIVATE_ENDPOINTS for split-DNS setups
This commit is contained in:
@@ -273,6 +273,7 @@ export function AuthTab() {
|
|||||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||||
|
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptPayload } from '@/lib/auth/crypto';
|
import { encryptPayload } from '@/lib/auth/crypto';
|
||||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
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 { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
|
||||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||||
|
|
||||||
if (!metadata?.authorization_endpoint) {
|
if (!metadata?.authorization_endpoint) {
|
||||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
@@ -52,9 +53,13 @@ async function tryTokenRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||||
|
// 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
|
// 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;
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
// 2. Try common Stalwart token endpoint paths directly
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let resolvedServerId: string | null = null;
|
let resolvedServerId: string | null = null;
|
||||||
|
let adminTrusted = false;
|
||||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||||
|
|
||||||
if (matchedEntry) {
|
if (matchedEntry) {
|
||||||
upstreamUrl = matchedEntry.url;
|
upstreamUrl = matchedEntry.url;
|
||||||
resolvedServerId = matchedEntry.id;
|
resolvedServerId = matchedEntry.id;
|
||||||
|
adminTrusted = true;
|
||||||
} else if (configuredServerUrl) {
|
} else if (configuredServerUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = configuredServerUrl;
|
||||||
|
adminTrusted = true;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
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 });
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||||
if (!tokenEndpoint) {
|
if (!tokenEndpoint) {
|
||||||
logger.warn('TOTP token exchange: no token endpoint found');
|
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 });
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
|||||||
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
|
|||||||
expect(consoleSpy).toHaveBeenCalled();
|
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 () => {
|
it('caches results - second call for same server URL does not re-fetch', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||||
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
|
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
|
||||||
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
|
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
|
||||||
|
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
|
||||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
|
// SSRF guard for OAuth discovery. When `oauthAllowPrivateEndpoints` is set,
|
||||||
|
// the admin opts in to discovery resolving to RFC-1918 / loopback hosts —
|
||||||
|
// required for split-DNS deployments where the JMAP server's public hostname
|
||||||
|
// resolves to an internal IP locally. The guard remains in force for any
|
||||||
|
// caller that passes a user-supplied serverUrl (see totp-token-exchange).
|
||||||
|
export function getDiscoveryValidator(): EndpointValidator | undefined {
|
||||||
|
const allowPrivate = configManager.get<boolean>('oauthAllowPrivateEndpoints', false);
|
||||||
|
return allowPrivate ? undefined : isPublicHttpUrl;
|
||||||
|
}
|
||||||
|
|
||||||
function getGlobalClientSecret(): string {
|
function getGlobalClientSecret(): string {
|
||||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||||
if (adminSecret) return adminSecret;
|
if (adminSecret) return adminSecret;
|
||||||
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
|
|||||||
|
|
||||||
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
|
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
|
||||||
const { discoveryUrl } = getRequiredConfig(serverId);
|
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||||
if (!metadata?.token_endpoint) {
|
if (!metadata?.token_endpoint) {
|
||||||
throw new Error('OAuth token endpoint not found');
|
throw new Error('OAuth token endpoint not found');
|
||||||
}
|
}
|
||||||
@@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise<string
|
|||||||
|
|
||||||
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
|
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
|
||||||
const { discoveryUrl } = getRequiredConfig(serverId);
|
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||||
return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
|
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
|
||||||
|
|||||||
Reference in New Issue
Block a user