diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index 5cb10c34..f3463c07 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -1,89 +1,9 @@ -import { lookup } from 'node:dns/promises'; -import { BlockList, isIP } from 'node:net'; import { NextRequest, NextResponse } from 'next/server'; +import { isPublicHttpUrl } from '@/lib/security/url-guard'; const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const FETCH_TIMEOUT_MS = 15000; -const blockedAddressRanges = new BlockList(); -blockedAddressRanges.addAddress('0.0.0.0'); -blockedAddressRanges.addAddress('127.0.0.1'); -blockedAddressRanges.addSubnet('10.0.0.0', 8); -blockedAddressRanges.addSubnet('172.16.0.0', 12); -blockedAddressRanges.addSubnet('192.168.0.0', 16); -blockedAddressRanges.addSubnet('169.254.0.0', 16); -blockedAddressRanges.addAddress('::', 'ipv6'); -blockedAddressRanges.addAddress('::1', 'ipv6'); -blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6'); -blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6'); - -function normalizeHostname(hostname: string): string { - return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); -} - -function isBlockedIpAddress(hostname: string): boolean { - const normalized = normalizeHostname(hostname); - const family = isIP(normalized); - if (family === 4) { - return blockedAddressRanges.check(normalized, 'ipv4'); - } - if (family === 6) { - return blockedAddressRanges.check(normalized, 'ipv6'); - } - return false; -} - -async function isValidExternalUrl(urlString: string): Promise { - let url: URL; - try { - url = new URL(urlString); - } catch { - return false; - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return false; - } - - const hostname = normalizeHostname(url.hostname); - - // Block private/internal hostnames - if ( - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname.endsWith('.local') || - hostname.endsWith('.internal') || - hostname.endsWith('.arpa') || - hostname.endsWith('.localdomain') - ) { - return false; - } - - // Block URLs with credentials - if (url.username || url.password) { - return false; - } - - if (isBlockedIpAddress(hostname)) { - return false; - } - - if (isIP(hostname)) { - return true; - } - - try { - const records = await lookup(hostname, { all: true, verbatim: true }); - if (records.length === 0) { - return false; - } - return records.every((record) => !isBlockedIpAddress(record.address)); - } catch { - return false; - } - -} - export async function POST(request: NextRequest) { let body: { url?: string }; try { @@ -98,7 +18,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'URL is required' }, { status: 400 }); } - if (!(await isValidExternalUrl(url))) { + if (!(await isPublicHttpUrl(url))) { return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); } @@ -111,7 +31,7 @@ export async function POST(request: NextRequest) { let response: Response | undefined; for (let i = 0; i <= MAX_REDIRECTS; i++) { - if (!(await isValidExternalUrl(currentUrl))) { + if (!(await isPublicHttpUrl(currentUrl))) { clearTimeout(timeout); return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 }); } @@ -131,7 +51,6 @@ export async function POST(request: NextRequest) { clearTimeout(timeout); return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 }); } - // Resolve relative redirects currentUrl = new URL(location, currentUrl).toString(); continue; } diff --git a/lib/__tests__/url-guard.test.ts b/lib/__tests__/url-guard.test.ts new file mode 100644 index 00000000..15f843b8 --- /dev/null +++ b/lib/__tests__/url-guard.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const lookup = vi.fn(); + +vi.mock('node:dns/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) }, + lookup: (...args: unknown[]) => lookup(...args), + }; +}); + +describe('isPublicHttpUrl', () => { + beforeEach(() => { + lookup.mockReset(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + async function load() { + const mod = await import('@/lib/security/url-guard'); + return mod.isPublicHttpUrl; + } + + it('accepts public https URLs whose DNS resolves to a public address', async () => { + lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://example.com/jmap')).toBe(true); + }); + + it('rejects malformed URLs', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('not a url')).toBe(false); + expect(await isPublicHttpUrl('')).toBe(false); + }); + + it('rejects non-http(s) protocols', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('file:///etc/passwd')).toBe(false); + expect(await isPublicHttpUrl('gopher://example.com/')).toBe(false); + expect(await isPublicHttpUrl('javascript:alert(1)')).toBe(false); + }); + + it('rejects URLs with embedded credentials', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://user:pass@example.com/')).toBe(false); + expect(await isPublicHttpUrl('https://user@example.com/')).toBe(false); + }); + + it('rejects loopback hostnames without DNS', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('http://localhost/')).toBe(false); + expect(await isPublicHttpUrl('http://service.localhost/')).toBe(false); + expect(await isPublicHttpUrl('http://server.local/')).toBe(false); + expect(await isPublicHttpUrl('http://kube.internal/api')).toBe(false); + expect(await isPublicHttpUrl('http://1.0.0.127.in-addr.arpa/')).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects literal IPv4 loopback and RFC-1918 ranges', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('http://127.0.0.1/')).toBe(false); + expect(await isPublicHttpUrl('http://10.0.0.5/')).toBe(false); + expect(await isPublicHttpUrl('http://10.255.255.255/')).toBe(false); + expect(await isPublicHttpUrl('http://172.16.0.1/')).toBe(false); + expect(await isPublicHttpUrl('http://172.31.255.254/')).toBe(false); + expect(await isPublicHttpUrl('http://192.168.1.1/')).toBe(false); + expect(await isPublicHttpUrl('http://0.0.0.0/')).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects literal AWS / GCP / Azure metadata IP', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false); + expect(await isPublicHttpUrl('http://169.254.0.1/')).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 loopback, ULA, and link-local literals', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('http://[::1]/')).toBe(false); + expect(await isPublicHttpUrl('http://[::]/')).toBe(false); + expect(await isPublicHttpUrl('http://[fc00::1]/')).toBe(false); + expect(await isPublicHttpUrl('http://[fd12:3456::1]/')).toBe(false); + expect(await isPublicHttpUrl('http://[fe80::1]/')).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects when DNS resolves to a private address (rebinding)', async () => { + lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://evil.example.com/')).toBe(false); + }); + + it('rejects when any resolved address is private (mixed)', async () => { + lookup.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://mixed.example.com/')).toBe(false); + }); + + it('rejects when DNS resolves to IPv6 loopback', async () => { + lookup.mockResolvedValue([{ address: '::1', family: 6 }]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://evil6.example.com/')).toBe(false); + }); + + it('rejects when DNS lookup throws', async () => { + lookup.mockRejectedValue(new Error('ENOTFOUND')); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://nonexistent.example.com/')).toBe(false); + }); + + it('rejects when DNS returns no records', async () => { + lookup.mockResolvedValue([]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://empty.example.com/')).toBe(false); + }); +}); diff --git a/lib/__tests__/verify-jmap-auth.test.ts b/lib/__tests__/verify-jmap-auth.test.ts new file mode 100644 index 00000000..1ccce302 --- /dev/null +++ b/lib/__tests__/verify-jmap-auth.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const lookup = vi.fn(); + +vi.mock('node:dns/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) }, + lookup: (...args: unknown[]) => lookup(...args), + }; +}); + +describe('verifyJmapAuth SSRF protection', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + lookup.mockReset(); + vi.resetModules(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + async function load() { + const mod = await import('@/lib/auth/verify-jmap-auth'); + return mod; + } + + it('rejects loopback literal without issuing fetch', async () => { + const { verifyJmapAuth, JmapAuthVerificationError } = await load(); + await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf( + JmapAuthVerificationError, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects AWS IMDS endpoint without issuing fetch', async () => { + const { verifyJmapAuth } = await load(); + await expect( + verifyJmapAuth('http://169.254.169.254', 'Bearer x'), + ).rejects.toMatchObject({ status: 400 }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects RFC-1918 literals without issuing fetch', async () => { + const { verifyJmapAuth } = await load(); + for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) { + await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 }); + } + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects localhost hostname without issuing fetch', async () => { + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 loopback literal without issuing fetch', async () => { + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => { + lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]); + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects file:// URLs', async () => { + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refuses to follow a redirect to a private address', async () => { + lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + fetchSpy.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }), + ); + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('refuses to follow a redirect to AWS IMDS', async () => { + lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + fetchSpy.mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: 'http://169.254.169.254/latest/meta-data/' }, + }), + ); + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('accepts a public host that returns a valid JMAP session', async () => { + lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe( + 'https://example.com', + ); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://example.com/.well-known/jmap', + expect.objectContaining({ redirect: 'manual' }), + ); + }); + + it('rejects an invalid Authorization header before any fetch', async () => { + const { verifyJmapAuth } = await load(); + await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({ + status: 400, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index fb4588c2..ba421e0c 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -1,4 +1,7 @@ +import { isPublicHttpUrl } from '@/lib/security/url-guard'; + const VERIFY_TIMEOUT_MS = 10000; +const MAX_REDIRECTS = 3; export class JmapAuthVerificationError extends Error { status: number; @@ -41,15 +44,47 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro const normalizedServerUrl = normalizeJmapServerUrl(serverUrl); validateProxyAuthHeader(authHeader); + if (!(await isPublicHttpUrl(normalizedServerUrl))) { + throw new JmapAuthVerificationError('Server URL is not allowed', 400); + } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS); try { - const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, { - method: 'GET', - headers: { Authorization: authHeader }, - signal: controller.signal, - }); + let currentUrl = `${normalizedServerUrl}/.well-known/jmap`; + let response: Response | undefined; + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + if (!(await isPublicHttpUrl(currentUrl))) { + throw new JmapAuthVerificationError('Server URL is not allowed', 400); + } + + response = await fetch(currentUrl, { + method: 'GET', + headers: { Authorization: authHeader }, + signal: controller.signal, + redirect: 'manual', + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + throw new JmapAuthVerificationError('Failed to verify JMAP session', 502); + } + currentUrl = new URL(location, currentUrl).toString(); + continue; + } + break; + } + + if (!response) { + throw new JmapAuthVerificationError('Failed to verify JMAP session', 502); + } + + if (response.status >= 300 && response.status < 400) { + throw new JmapAuthVerificationError('Too many redirects verifying JMAP session', 502); + } if (!response.ok) { throw new JmapAuthVerificationError( @@ -77,4 +112,4 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro } finally { clearTimeout(timeout); } -} \ No newline at end of file +} diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts new file mode 100644 index 00000000..aa0a7bd8 --- /dev/null +++ b/lib/security/url-guard.ts @@ -0,0 +1,67 @@ +import { lookup } from 'node:dns/promises'; +import { BlockList, isIP } from 'node:net'; + +const blockedAddressRanges = new BlockList(); +blockedAddressRanges.addAddress('0.0.0.0'); +blockedAddressRanges.addAddress('127.0.0.1'); +blockedAddressRanges.addSubnet('10.0.0.0', 8); +blockedAddressRanges.addSubnet('172.16.0.0', 12); +blockedAddressRanges.addSubnet('192.168.0.0', 16); +blockedAddressRanges.addSubnet('169.254.0.0', 16); +blockedAddressRanges.addAddress('::', 'ipv6'); +blockedAddressRanges.addAddress('::1', 'ipv6'); +blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6'); +blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6'); + +const BLOCKED_HOSTNAMES = new Set(['localhost']); +const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain']; + +function normalizeHostname(hostname: string): string { + return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); +} + +function isBlockedIpAddress(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + const family = isIP(normalized); + if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4'); + if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6'); + return false; +} + +/** + * Returns true only when the URL targets a public host reachable over http(s). + * Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname + * suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials, + * and any hostname whose DNS resolves to a blocked address. + * + * Note: there is a TOCTOU window between this lookup and the eventual fetch(). + * Callers that need rebinding-safe behavior must additionally pin the resolved + * IP at connect time (e.g. via a custom undici dispatcher). + */ +export async function isPublicHttpUrl(urlString: string): Promise { + let url: URL; + try { + url = new URL(urlString); + } catch { + return false; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + if (url.username || url.password) return false; + + const hostname = normalizeHostname(url.hostname); + if (!hostname) return false; + if (BLOCKED_HOSTNAMES.has(hostname)) return false; + if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false; + + if (isBlockedIpAddress(hostname)) return false; + if (isIP(hostname)) return true; + + try { + const records = await lookup(hostname, { all: true, verbatim: true }); + if (records.length === 0) return false; + return records.every((record) => !isBlockedIpAddress(record.address)); + } catch { + return false; + } +} diff --git a/locales/en/common.json b/locales/en/common.json index ff1c2a05..026d45fc 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -454,6 +454,9 @@ "discard_draft_title": "Discard draft?", "discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?", "saving": "Saving...", + "sending": "Sending...", + "add_link": "Add link", + "link_url_prompt": "Enter the URL", "draft_saved": "Draft saved", "save_failed": "Failed to save", "to_placeholder": "Recipient email addresses",