fix: add OAUTH_ALLOW_PRIVATE_ENDPOINTS for split-DNS setups

This commit is contained in:
Linus Rath
2026-05-22 17:22:14 +02:00
parent e843ef0ebb
commit 63f2169ae7
6 changed files with 45 additions and 9 deletions
+17
View File
@@ -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,
+1
View File
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_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 },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
+13 -3
View File
@@ -1,11 +1,21 @@
import { logger } from '@/lib/logger';
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 { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
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 {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
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<string
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
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 {