import * as WebhookDestination from '../WebhookDestination.js' /** * Builds a `url` destination. Its `deliver` serializes the envelope, signs the * raw body with the subscription secret (HMAC in `tempo-signature`), re-runs the * SSRF guard, and POSTs it rejecting redirects. The URL is SSRF-validated at * construction time too. Deliver with `.deliver({ envelope, secret })`. */ export function url(url: string): WebhookDestination.Instance { assertDeliverableUrl(url) return { async deliver(options) { const { envelope } = options // Defense-in-depth: re-run the static SSRF guard at delivery time so a // stored URL that no longer passes (e.g. a tightened block list) is never // POSTed to. try { assertDeliverableUrl(url) } catch (cause) { const reason = cause instanceof WebhookDestination.InvalidUrlError ? cause.reason : 'malformed' return { error: `url rejected (${reason})`, ok: false } } if (options.secret === undefined) return { error: 'url destination requires a secret', ok: false } const body = JSON.stringify(envelope) return WebhookDestination.send({ body, fetch: options.fetch, headers: { [WebhookDestination.eventIdHeader]: envelope.id, [WebhookDestination.eventTypeHeader]: envelope.type, [WebhookDestination.signatureHeader]: WebhookDestination.sign({ body, secret: options.secret, timestamp: options.timestamp, }), }, now: options.now, // Never follow redirects: a 3xx could point at a private/metadata host // and bypass the SSRF guard, which only validates the original URL. rejectRedirects: true, timeoutMs: options.timeoutMs, url, }) }, type: 'url', url, } } /** * Validates a subscriber URL, rejecting non-`https`, embedded credentials, and * private/loopback/link-local/metadata hosts. Returns the parsed URL. * * This is a static guard; DNS-rebinding defense (resolving + re-checking at * delivery time) is layered on later. */ export function assertDeliverableUrl(input: string): URL { let url: URL try { url = new URL(input) } catch { throw new WebhookDestination.InvalidUrlError('malformed', input) } if (url.protocol !== 'https:') throw new WebhookDestination.InvalidUrlError('protocol', input) if (url.username || url.password) throw new WebhookDestination.InvalidUrlError('credentials', input) if (Hostname.isBlocked(url.hostname)) throw new WebhookDestination.InvalidUrlError('blocked_host', input) return url } namespace Hostname { export function isBlocked(hostname: string): boolean { // URL hostnames preserve IPv6 brackets and are already lowercased for names. const host = hostname.replace(/^\[|\]$/g, '').toLowerCase() if (host === 'localhost' || host.endsWith('.localhost')) return true if (host === 'metadata.google.internal') return true if (host.length === 0) return true if (isIpv4(host)) return isBlockedIpv4(host) if (host.includes(':')) return isBlockedIpv6(host) return false } function isIpv4(host: string): boolean { const parts = host.split('.') return ( parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) ) } function isBlockedIpv4(host: string): boolean { const [a, b] = host.split('.').map(Number) as [number, number, number, number] if (a === 0 || a === 10 || a === 127) return true // unspecified, private, loopback if (a === 169 && b === 254) return true // link-local (incl. cloud metadata) if (a === 172 && b >= 16 && b <= 31) return true // private if (a === 192 && b === 168) return true // private if (a === 100 && b >= 64 && b <= 127) return true // carrier-grade NAT return false } function isBlockedIpv6(host: string): boolean { if (host === '::' || host === '::1') return true // unspecified, loopback if ( host.startsWith('fe8') || host.startsWith('fe9') || host.startsWith('fea') || host.startsWith('feb') ) return true // link-local fe80::/10 if (host.startsWith('fc') || host.startsWith('fd')) return true // unique local fc00::/7 // IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4. const dotted = host.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) if (dotted?.[1] && isIpv4(dotted[1])) return isBlockedIpv4(dotted[1]) // The URL parser compresses ::ffff:a.b.c.d to two hex groups (e.g. 7f00:1). const hex = host.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) if (hex?.[1] && hex[2]) { const high = Number.parseInt(hex[1], 16) const low = Number.parseInt(hex[2], 16) const ipv4 = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}` return isBlockedIpv4(ipv4) } return false } }