diff --git a/lib/__tests__/oauth-discovery.test.ts b/lib/__tests__/oauth-discovery.test.ts index a330377d..9e996f38 100644 --- a/lib/__tests__/oauth-discovery.test.ts +++ b/lib/__tests__/oauth-discovery.test.ts @@ -1,6 +1,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { OAuthMetadata } from '../oauth/discovery'; +vi.mock('../security/url-guard', () => ({ + isPublicHttpUrl: vi.fn(async (urlString: string) => { + try { + const url = new URL(urlString); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + if (url.username || url.password) return false; + const host = url.hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false; + if (/^(127\.|169\.254\.|10\.|192\.168\.)/.test(host)) return false; + if (host === '::1' || host === '0.0.0.0') return false; + return true; + } catch { + return false; + } + }), +})); + const VALID_METADATA: OAuthMetadata = { issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', @@ -92,6 +109,45 @@ describe('oauth/discovery', () => { expect(consoleSpy).toHaveBeenCalled(); }); + it('rejects metadata pointing at loopback / link-local hosts (SSRF guard)', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + issuer: 'https://evil.example.com', + authorization_endpoint: 'https://evil.example.com/authorize', + token_endpoint: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + }), + }) + .mockResolvedValueOnce({ ok: false, status: 404 })); + + const result = await discoverOAuth('https://evil.example.com'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('rejects metadata pointing at private RFC1918 hosts (SSRF guard)', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + issuer: 'https://evil.example.com', + authorization_endpoint: 'https://evil.example.com/authorize', + token_endpoint: 'https://evil.example.com/token', + revocation_endpoint: 'http://127.0.0.1:9200/_cluster/state', + }), + }) + .mockResolvedValueOnce({ ok: false, status: 404 })); + + const result = await discoverOAuth('https://private-revoke.example.com'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + }); + 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/oauth/discovery.ts b/lib/oauth/discovery.ts index 8a7eab55..84178d20 100644 --- a/lib/oauth/discovery.ts +++ b/lib/oauth/discovery.ts @@ -1,3 +1,5 @@ +import { isPublicHttpUrl } from '../security/url-guard'; + export interface OAuthMetadata { issuer: string; authorization_endpoint: string; @@ -22,6 +24,20 @@ function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void { metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS }); } +// Endpoints come from an attacker-controllable JSON document when callers pass +// a user-supplied serverUrl (e.g. /api/auth/totp-token-exchange under +// allowCustomJmapEndpoint). Without this gate, a malicious metadata document +// could point token_endpoint at 169.254.169.254 or 127.0.0.1:* and turn the +// downstream fetch() into an SSRF with response-body reflection. +async function endpointsArePublic(endpoints: Array): Promise { + for (const endpoint of endpoints) { + if (endpoint === undefined) continue; + if (typeof endpoint !== 'string') return false; + if (!(await isPublicHttpUrl(endpoint))) return false; + } + return true; +} + export async function discoverOAuth(serverUrl: string): Promise { const cached = metadataCache.get(serverUrl); if (cached && cached.expiresAt > Date.now()) return cached.metadata; @@ -44,6 +60,16 @@ export async function discoverOAuth(serverUrl: string): Promise