From 236eeff909fe3f3823e2240332ad410109dad728 Mon Sep 17 00:00:00 2001 From: adebayodeolu Date: Fri, 29 May 2026 21:31:49 +0000 Subject: [PATCH] feat(security): add outbound SSRF protection to webhook and provider URLs (#640) --- backend/src/schemas/webhook.ts | 16 +- backend/src/services/webhook-service.ts | 19 +- backend/src/utils/ssrf-protection.ts | 334 ++++++++++++++++++++++++ backend/tests/ssrf-protection.test.t | 302 +++++++++++++++++++++ 4 files changed, 661 insertions(+), 10 deletions(-) create mode 100644 backend/src/utils/ssrf-protection.ts create mode 100644 backend/tests/ssrf-protection.test.t diff --git a/backend/src/schemas/webhook.ts b/backend/src/schemas/webhook.ts index 9d6c11be..42447dd3 100644 --- a/backend/src/schemas/webhook.ts +++ b/backend/src/schemas/webhook.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { validateOutboundUrlSync } from '../utils/ssrf-protection'; const webhookEventSchema = z.enum([ 'subscription.renewal_due', @@ -15,14 +16,13 @@ const webhookUrlSchema = z .url('Must be a valid URL') .refine( (val) => { - try { - const { protocol } = new URL(val); - return protocol === 'http:' || protocol === 'https:'; - } catch { - return false; - } + const result = validateOutboundUrlSync(val); + return result.valid; + }, + (val) => { + const result = validateOutboundUrlSync(val); + return { message: result.reason ?? 'URL is not permitted as a webhook target' }; }, - { message: 'URL must use http or https protocol' }, ); export const createWebhookSchema = z.object({ @@ -49,4 +49,4 @@ export const updateWebhookSchema = z.object({ .string() .max(255, 'Description must not exceed 255 characters') .optional(), -}); +}); \ No newline at end of file diff --git a/backend/src/services/webhook-service.ts b/backend/src/services/webhook-service.ts index c7c67b32..49ad0f6b 100644 --- a/backend/src/services/webhook-service.ts +++ b/backend/src/services/webhook-service.ts @@ -9,6 +9,7 @@ import { WebhookCreateInput, WebhookUpdateInput } from '../types/webhook'; +import { validateOutboundUrl, SSRFError } from '../utils/ssrf-protection'; export class WebhookService { private readonly MAX_RETRIES = 5; @@ -211,8 +212,22 @@ export class WebhookService { .createHmac('sha256', webhook.secret) .update(payloadString) .digest('hex'); - - try { + try { + // SSRF guard — re-validate at dispatch time to cover stored URLs and + // DNS rebinding attacks (a URL that was safe at registration may resolve + // to a private IP by the time delivery is attempted). + try { + await validateOutboundUrl(webhook.url); + } catch (ssrfErr) { + const reason = ssrfErr instanceof SSRFError ? ssrfErr.message : String(ssrfErr); + logger.warn('Webhook delivery blocked by SSRF protection', { + webhookId: webhook.id, + url: webhook.url, + reason, + }); + return await this.handleDeliveryFailure(deliveryId, webhook.id, 0, `SSRF_BLOCKED: ${reason}`); + } + const response = await fetch(webhook.url, { method: 'POST', headers: { diff --git a/backend/src/utils/ssrf-protection.ts b/backend/src/utils/ssrf-protection.ts new file mode 100644 index 00000000..d3a3d404 --- /dev/null +++ b/backend/src/utils/ssrf-protection.ts @@ -0,0 +1,334 @@ +/** + * SSRF (Server-Side Request Forgery) Protection Utility + * + * Validates URLs supplied for outbound requests (webhooks, provider APIs, + * calendar integrations) to ensure they do not target private networks, + * loopback addresses, link-local ranges, or cloud-metadata endpoints. + * + * Issue: #640 + */ + +import dns from 'dns/promises'; + +// --------------------------------------------------------------------------- +// Allow / Deny rule configuration +// --------------------------------------------------------------------------- + +/** + * Protocols that are permitted for outbound requests. + * Only HTTPS is allowed in production; HTTP is permitted in development/test + * when the allowHttp option is passed explicitly. + */ +export const ALLOWED_PROTOCOLS = new Set(['https:']); + +/** + * Cloud instance metadata service hostnames that must always be blocked, + * regardless of resolved IP address. + * + * References: + * - AWS: 169.254.169.254 + * - GCP: metadata.google.internal / 169.254.169.254 + * - Azure: 169.254.169.254 + * - DigitalOcean: 169.254.169.254 + */ +export const BLOCKED_HOSTNAMES = new Set([ + 'metadata.google.internal', + 'metadata.goog', + 'instance-data', + 'instance-data.ec2.internal', +]); + +/** + * Private/reserved IPv4 CIDR ranges. + * + * Covers: + * - Loopback 127.0.0.0/8 + * - Private (Class A) 10.0.0.0/8 + * - Private (Class B) 172.16.0.0/12 + * - Private (Class C) 192.168.0.0/16 + * - Link-local / IMDS 169.254.0.0/16 + * - CGNAT 100.64.0.0/10 + * - "This" network 0.0.0.0/8 + * - TEST-NET-1..3 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + * - Reserved 240.0.0.0/4 + * - Broadcast 255.255.255.255 + * + * NOTE: We use multiplication instead of bit-shifts to avoid JavaScript's + * signed-integer truncation when the high bit is set (e.g. 172.x, 192.x). + */ +const BLOCKED_IPV4_RANGES: Array<{ label: string; base: number; mask: number }> = [ + { label: 'loopback', base: ipToUint32('127.0.0.0'), mask: 0xff000000 }, + { label: 'RFC-1918-A', base: ipToUint32('10.0.0.0'), mask: 0xff000000 }, + { label: 'RFC-1918-B', base: ipToUint32('172.16.0.0'), mask: 0xfff00000 }, + { label: 'RFC-1918-C', base: ipToUint32('192.168.0.0'), mask: 0xffff0000 }, + { label: 'link-local/IMDS', base: ipToUint32('169.254.0.0'), mask: 0xffff0000 }, + { label: 'CGNAT', base: ipToUint32('100.64.0.0'), mask: 0xffc00000 }, + { label: 'this-network', base: ipToUint32('0.0.0.0'), mask: 0xff000000 }, + { label: 'TEST-NET-1', base: ipToUint32('192.0.2.0'), mask: 0xffffff00 }, + { label: 'TEST-NET-2', base: ipToUint32('198.51.100.0'),mask: 0xffffff00 }, + { label: 'TEST-NET-3', base: ipToUint32('203.0.113.0'), mask: 0xffffff00 }, + { label: 'reserved', base: ipToUint32('240.0.0.0'), mask: 0xf0000000 }, + { label: 'broadcast', base: ipToUint32('255.255.255.255'), mask: 0xffffffff }, +]; + +// --------------------------------------------------------------------------- +// Helper: IPv4 utilities +// --------------------------------------------------------------------------- + +/** + * Convert a dotted-decimal IPv4 string to an unsigned 32-bit integer. + * Uses multiplication (not bit-shifts) to avoid JS signed-integer overflow. + */ +function ipToUint32(ip: string): number { + const parts = ip.split('.').map(Number); + return ( + parts[0] * 16777216 + // << 24 + parts[1] * 65536 + // << 16 + parts[2] * 256 + // << 8 + parts[3] + ); +} + +function isValidIPv4(ip: string): boolean { + const parts = ip.split('.'); + if (parts.length !== 4) return false; + return parts.every((p) => { + const n = Number(p); + return !isNaN(n) && n >= 0 && n <= 255 && String(n) === p; + }); +} + +function isPrivateIPv4(ip: string): boolean { + if (!isValidIPv4(ip)) return false; + const n = ipToUint32(ip); + return BLOCKED_IPV4_RANGES.some(({ base, mask }) => (n & mask) >>> 0 === base >>> 0); +} + +// --------------------------------------------------------------------------- +// Helper: IPv6 utilities +// --------------------------------------------------------------------------- + +/** + * Parse an IPv4-mapped IPv6 address in the hex-group form that Node's URL + * parser normalises to, e.g. ::ffff:c0a8:101 → "192.168.1.1". + * + * The URL spec normalises ::ffff:192.168.1.1 to ::ffff:c0a8:101 before + * storing it as the hostname, so we must handle both forms. + */ +function extractIPv4FromMappedIPv6(lower: string): string | null { + // Dotted-decimal form: ::ffff:192.168.1.1 + const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) return dotted[1]; + + // Hex-group form (browser / Node URL normalisation): ::ffff:c0a8:101 + const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const high = parseInt(hex[1], 16); + const low = parseInt(hex[2], 16); + return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.'); + } + + return null; +} + +function isPrivateIPv6(ip: string): boolean { + // Strip surrounding brackets that may appear from URL.hostname + const lower = ip.toLowerCase().replace(/^\[/, '').replace(/\]$/, ''); + + // Loopback + if (lower === '::1') return true; + // Unspecified / any-address + if (lower === '::') return true; + + // Link-local fe80::/10 + if (/^fe[89ab][0-9a-f]:/i.test(lower)) return true; + + // Unique-local fc00::/7 + if (/^f[cd][0-9a-f]{2}:/i.test(lower)) return true; + + // IPv4-mapped ::ffff:x.x.x.x (both dotted and hex-group forms) + const mapped = extractIPv4FromMappedIPv6(lower); + if (mapped !== null) return isPrivateIPv4(mapped); + + return false; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export class SSRFError extends Error { + constructor( + message: string, + public readonly code: SSRFErrorCode, + ) { + super(message); + this.name = 'SSRFError'; + } +} + +export type SSRFErrorCode = + | 'INVALID_URL' + | 'DISALLOWED_PROTOCOL' + | 'BLOCKED_HOSTNAME' + | 'PRIVATE_IP' + | 'DNS_RESOLUTION_FAILED'; + +export interface SSRFValidationOptions { + /** + * Allow HTTP in addition to HTTPS. Defaults to false. + * Only enable this in development / test environments. + */ + allowHttp?: boolean; + + /** + * Perform a DNS lookup and validate the resolved IP(s). + * Defaults to true. Disable only in unit tests. + */ + resolveDns?: boolean; +} + +/** + * Validates that a URL is safe to use as an outbound request target. + * + * Throws an {@link SSRFError} with a descriptive `code` if the URL fails + * any check. Returns the parsed {@link URL} object on success so callers + * can reuse it without re-parsing. + * + * @example + * ```ts + * // In webhook dispatch: + * await validateOutboundUrl(webhook.url); + * const response = await fetch(webhook.url, { ... }); + * ``` + */ +export async function validateOutboundUrl( + rawUrl: string, + options: SSRFValidationOptions = {}, +): Promise { + const { allowHttp = false, resolveDns = true } = options; + + // 1. Parse + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new SSRFError(`Invalid URL: ${rawUrl}`, 'INVALID_URL'); + } + + // 2. Protocol check + const allowedProtocols = allowHttp + ? new Set([...ALLOWED_PROTOCOLS, 'http:']) + : ALLOWED_PROTOCOLS; + + if (!allowedProtocols.has(parsed.protocol)) { + throw new SSRFError( + `Disallowed protocol "${parsed.protocol}". Only ${[...allowedProtocols].join(', ')} are permitted.`, + 'DISALLOWED_PROTOCOL', + ); + } + + // 3. Hostname blocklist (e.g. metadata.google.internal) + const hostname = parsed.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(hostname)) { + throw new SSRFError( + `Hostname "${hostname}" is explicitly blocked.`, + 'BLOCKED_HOSTNAME', + ); + } + + // 4. Reject if the hostname is already a private/loopback IP (no DNS needed) + if (isPrivateIPv4(hostname)) { + throw new SSRFError( + `Target IP "${hostname}" is in a private or reserved range.`, + 'PRIVATE_IP', + ); + } + + if (isPrivateIPv6(hostname)) { + throw new SSRFError( + `Target IPv6 address "${hostname}" is in a private or reserved range.`, + 'PRIVATE_IP', + ); + } + + // 5. DNS resolution — guard against DNS rebinding / split-horizon attacks + if (resolveDns) { + let addresses: string[]; + try { + const result = await dns.lookup(hostname, { all: true }); + addresses = result.map((r) => r.address); + } catch (err) { + throw new SSRFError( + `DNS resolution failed for "${hostname}": ${err instanceof Error ? err.message : String(err)}`, + 'DNS_RESOLUTION_FAILED', + ); + } + + for (const address of addresses) { + if (isPrivateIPv4(address)) { + throw new SSRFError( + `"${hostname}" resolves to a private IP address "${address}".`, + 'PRIVATE_IP', + ); + } + if (isPrivateIPv6(address)) { + throw new SSRFError( + `"${hostname}" resolves to a private IPv6 address "${address}".`, + 'PRIVATE_IP', + ); + } + } + } + + return parsed; +} + +/** + * Synchronous URL validation without DNS resolution. + * Suitable for Zod `.refine()` callbacks and request-time schema validation + * where async is not supported. + * + * DNS-based rebinding protection is NOT available in this variant; always + * pair with the async {@link validateOutboundUrl} before making the actual + * outbound request. + */ +export function validateOutboundUrlSync( + rawUrl: string, + allowHttp = false, +): { valid: boolean; reason?: string } { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return { valid: false, reason: 'Invalid URL' }; + } + + const allowedProtocols = allowHttp + ? new Set([...ALLOWED_PROTOCOLS, 'http:']) + : ALLOWED_PROTOCOLS; + + if (!allowedProtocols.has(parsed.protocol)) { + return { + valid: false, + reason: `Disallowed protocol "${parsed.protocol}". Only ${[...allowedProtocols].join(', ')} are permitted.`, + }; + } + + const hostname = parsed.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(hostname)) { + return { valid: false, reason: `Hostname "${hostname}" is explicitly blocked.` }; + } + + if (isPrivateIPv4(hostname)) { + return { valid: false, reason: `Target IP "${hostname}" is in a private or reserved range.` }; + } + + if (isPrivateIPv6(hostname)) { + return { valid: false, reason: `Target IPv6 "${hostname}" is in a private or reserved range.` }; + } + + return { valid: true }; +} \ No newline at end of file diff --git a/backend/tests/ssrf-protection.test.t b/backend/tests/ssrf-protection.test.t new file mode 100644 index 00000000..cb51b8cf --- /dev/null +++ b/backend/tests/ssrf-protection.test.t @@ -0,0 +1,302 @@ +/** + * Tests for SSRF (Server-Side Request Forgery) protection utility + * Issue: #640 + */ + +import dns from 'dns/promises'; +import { + validateOutboundUrl, + validateOutboundUrlSync, + SSRFError, + BLOCKED_HOSTNAMES, + ALLOWED_PROTOCOLS, +} from '../src/utils/ssrf-protection'; + +// --------------------------------------------------------------------------- +// Mock dns.lookup so tests don't make real network calls +// --------------------------------------------------------------------------- +jest.mock('dns/promises'); +const mockDnsLookup = dns.lookup as jest.MockedFunction; + +function mockDns(addresses: string[]) { + mockDnsLookup.mockResolvedValue( + addresses.map((address) => ({ address, family: address.includes(':') ? 6 : 4 })) as any, + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + // Default: resolve to a safe public IP + mockDns(['93.184.216.34']); // example.com +}); + +// =========================================================================== +// validateOutboundUrl (async, with DNS) +// =========================================================================== + +describe('validateOutboundUrl', () => { + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + describe('valid URLs', () => { + it('accepts a public HTTPS URL', async () => { + const url = await validateOutboundUrl('https://example.com/webhook'); + expect(url.hostname).toBe('example.com'); + }); + + it('returns a URL object on success', async () => { + const url = await validateOutboundUrl('https://hooks.slack.com/services/T000/B000/xxx'); + expect(url).toBeInstanceOf(URL); + }); + + it('accepts HTTPS with a non-standard port', async () => { + await expect(validateOutboundUrl('https://example.com:8443/hook')).resolves.toBeDefined(); + }); + + it('allows HTTP when allowHttp option is true', async () => { + await expect( + validateOutboundUrl('http://example.com/hook', { allowHttp: true }), + ).resolves.toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Protocol enforcement + // ------------------------------------------------------------------------- + describe('protocol checks', () => { + it('rejects HTTP by default', async () => { + await expect(validateOutboundUrl('http://example.com/hook')).rejects.toThrow(SSRFError); + await expect(validateOutboundUrl('http://example.com/hook')).rejects.toMatchObject({ + code: 'DISALLOWED_PROTOCOL', + }); + }); + + it('rejects ftp:// URLs', async () => { + await expect(validateOutboundUrl('ftp://example.com/file')).rejects.toMatchObject({ + code: 'DISALLOWED_PROTOCOL', + }); + }); + + it('rejects file:// URLs', async () => { + await expect(validateOutboundUrl('file:///etc/passwd')).rejects.toMatchObject({ + code: 'DISALLOWED_PROTOCOL', + }); + }); + + it('rejects javascript: URLs', async () => { + await expect(validateOutboundUrl('javascript:alert(1)')).rejects.toMatchObject({ + code: 'DISALLOWED_PROTOCOL', + }); + }); + + it('rejects data: URLs', async () => { + await expect(validateOutboundUrl('data:text/html,

x

')).rejects.toMatchObject({ + code: 'DISALLOWED_PROTOCOL', + }); + }); + }); + + // ------------------------------------------------------------------------- + // Invalid URL format + // ------------------------------------------------------------------------- + describe('invalid URL format', () => { + it('rejects empty string', async () => { + await expect(validateOutboundUrl('')).rejects.toMatchObject({ code: 'INVALID_URL' }); + }); + + it('rejects plain hostname without scheme', async () => { + await expect(validateOutboundUrl('example.com')).rejects.toMatchObject({ code: 'INVALID_URL' }); + }); + + it('rejects garbage string', async () => { + await expect(validateOutboundUrl('not a url at all')).rejects.toMatchObject({ + code: 'INVALID_URL', + }); + }); + }); + + // ------------------------------------------------------------------------- + // Blocked hostnames (metadata endpoints) + // ------------------------------------------------------------------------- + describe('blocked hostnames', () => { + for (const host of BLOCKED_HOSTNAMES) { + it(`rejects explicitly blocked hostname: ${host}`, async () => { + await expect(validateOutboundUrl(`https://${host}/path`)).rejects.toMatchObject({ + code: 'BLOCKED_HOSTNAME', + }); + }); + } + }); + + // ------------------------------------------------------------------------- + // Private IPv4 ranges (literal IPs in URL) + // ------------------------------------------------------------------------- + describe('private IPv4 literals', () => { + const privateCases = [ + ['loopback', 'https://127.0.0.1/hook'], + ['loopback high', 'https://127.255.0.1/hook'], + ['RFC-1918 Class A', 'https://10.0.0.1/hook'], + ['RFC-1918 Class A (high)', 'https://10.255.255.1/hook'], + ['RFC-1918 Class B', 'https://172.16.0.1/hook'], + ['RFC-1918 Class B boundary', 'https://172.31.255.255/hook'], + ['RFC-1918 Class C', 'https://192.168.1.100/hook'], + ['link-local / IMDS', 'https://169.254.169.254/latest/meta-data/'], + ['CGNAT', 'https://100.64.0.1/hook'], + ]; + + for (const [label, url] of privateCases) { + it(`blocks ${label}: ${url}`, async () => { + await expect(validateOutboundUrl(url)).rejects.toMatchObject({ code: 'PRIVATE_IP' }); + }); + } + }); + + // ------------------------------------------------------------------------- + // Private IPv6 ranges (literal IPs in URL) + // ------------------------------------------------------------------------- + describe('private IPv6 literals', () => { + it('blocks loopback ::1', async () => { + await expect(validateOutboundUrl('https://[::1]/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('blocks link-local fe80::1', async () => { + await expect(validateOutboundUrl('https://[fe80::1]/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('blocks unique-local fc00::1', async () => { + await expect(validateOutboundUrl('https://[fc00::1]/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('blocks IPv4-mapped ::ffff:192.168.1.1', async () => { + await expect(validateOutboundUrl('https://[::ffff:192.168.1.1]/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + }); + + // ------------------------------------------------------------------------- + // DNS resolution checks (rebinding / split-horizon attacks) + // ------------------------------------------------------------------------- + describe('DNS resolution', () => { + it('blocks a hostname that resolves to a private IP', async () => { + mockDns(['10.0.0.50']); + await expect(validateOutboundUrl('https://evil.example.com/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('blocks a hostname that resolves to the loopback', async () => { + mockDns(['127.0.0.1']); + await expect(validateOutboundUrl('https://evil.example.com/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('blocks a hostname that resolves to the IMDS address', async () => { + mockDns(['169.254.169.254']); + await expect(validateOutboundUrl('https://evil.example.com/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('blocks a hostname resolving to a private IPv6 address', async () => { + mockDns(['fe80::1']); + await expect(validateOutboundUrl('https://evil.example.com/hook')).rejects.toMatchObject({ + code: 'PRIVATE_IP', + }); + }); + + it('fails gracefully when DNS lookup throws', async () => { + mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND')); + await expect(validateOutboundUrl('https://nonexistent.invalid/hook')).rejects.toMatchObject({ + code: 'DNS_RESOLUTION_FAILED', + }); + }); + + it('skips DNS when resolveDns=false', async () => { + // Even if DNS would return a private IP, we skip it + mockDns(['10.0.0.1']); + await expect( + validateOutboundUrl('https://example.com/hook', { resolveDns: false }), + ).resolves.toBeDefined(); + expect(mockDnsLookup).not.toHaveBeenCalled(); + }); + + it('accepts a hostname that resolves to a public IP', async () => { + mockDns(['93.184.216.34']); + await expect(validateOutboundUrl('https://example.com/hook')).resolves.toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + describe('edge cases', () => { + it('is case-insensitive for hostnames', async () => { + await expect(validateOutboundUrl('https://METADATA.GOOGLE.INTERNAL/')).rejects.toMatchObject({ + code: 'BLOCKED_HOSTNAME', + }); + }); + + it('handles IPv4-mapped IPv6 IMDS address ::ffff:169.254.169.254', async () => { + await expect( + validateOutboundUrl('https://[::ffff:169.254.169.254]/hook'), + ).rejects.toMatchObject({ code: 'PRIVATE_IP' }); + }); + }); +}); + +// =========================================================================== +// validateOutboundUrlSync (no DNS) +// =========================================================================== + +describe('validateOutboundUrlSync', () => { + it('returns valid=true for a public HTTPS URL', () => { + expect(validateOutboundUrlSync('https://example.com/hook')).toEqual({ valid: true }); + }); + + it('returns valid=false with reason for HTTP', () => { + const result = validateOutboundUrlSync('https://127.0.0.1/hook'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/private|reserved/i); + }); + + it('returns valid=false for blocked hostname', () => { + const result = validateOutboundUrlSync('https://metadata.google.internal/'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/blocked/i); + }); + + it('returns valid=false with reason for invalid URL', () => { + const result = validateOutboundUrlSync('not-a-url'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/invalid url/i); + }); + + it('allows HTTP when allowHttp=true', () => { + expect(validateOutboundUrlSync('http://example.com/hook', true)).toEqual({ valid: true }); + }); +}); + +// =========================================================================== +// Constants sanity checks +// =========================================================================== + +describe('configuration constants', () => { + it('ALLOWED_PROTOCOLS only contains https: by default', () => { + expect(ALLOWED_PROTOCOLS.has('https:')).toBe(true); + expect(ALLOWED_PROTOCOLS.has('http:')).toBe(false); + expect(ALLOWED_PROTOCOLS.has('ftp:')).toBe(false); + }); + + it('BLOCKED_HOSTNAMES contains known metadata endpoints', () => { + expect(BLOCKED_HOSTNAMES.has('metadata.google.internal')).toBe(true); + expect(BLOCKED_HOSTNAMES.has('metadata.goog')).toBe(true); + }); +}); \ No newline at end of file