Files
accounted/lib/webhooks/url-guard.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

185 lines
6.7 KiB
TypeScript

/**
* Webhook URL safety guard.
*
* SSRF mitigation for the dispatcher: a webhook receiver URL is supplied by
* the caller, and the dispatcher POSTs HMAC-signed payloads to it from the
* Vercel function's network position. Without validation, a malicious
* caller could direct the dispatcher at internal addresses (cloud metadata
* endpoints at 169.254.169.254, kube-internal services at 10.x, loopback,
* etc.) and exfiltrate signed payloads or probe internal infrastructure.
*
* Two-layer defense:
* 1. At create / update time the v1 routes call `assertSafeWebhookUrl`
* and reject the request with VALIDATION_ERROR if the URL fails.
* 2. At dispatch time the dispatcher calls the same helper before each
* HTTP request: DNS records can change between creation and
* dispatch (rebind attacks, DNS hijack), so the create-time check
* alone is insufficient.
*
* Errors carry a stable `reason` string so the route can surface a
* structured details object and the dispatcher can stamp it on the
* delivery's error column.
*/
import { promises as dns } from 'node:dns'
export type WebhookUrlValidationReason =
| 'invalid_url'
| 'non_https_scheme'
| 'dns_lookup_failed'
| 'no_dns_records'
| 'private_address'
| 'loopback_address'
| 'link_local_address'
| 'cgnat_address'
| 'metadata_address'
export interface WebhookUrlValidationError {
ok: false
reason: WebhookUrlValidationReason
detail: string
}
export interface WebhookUrlValidationOk {
ok: true
hostname: string
/** All A/AAAA records resolved at validation time. Every entry is publicly routable. */
resolvedAddresses: string[]
}
export type WebhookUrlValidationResult = WebhookUrlValidationOk | WebhookUrlValidationError
/**
* Validate that the URL is HTTPS and that EVERY A/AAAA record for the
* hostname resolves to a publicly-routable address. Returns a
* discriminated result rather than throwing so call sites can surface a
* clean validation error envelope.
*
* Multi-record enumeration (vs single dns.lookup) closes a round-robin
* DNS bypass: a hostname with two A records [public, private] returns
* either non-deterministically per call. Single-lookup validation could
* return the public IP at create time and the private IP at dispatch
* time. Resolving ALL records and rejecting if ANY is unsafe forecloses
* that path. A separate DNS-rebinding window (between dispatch-time
* validation and the actual fetch) remains; closing that requires a
* custom HTTPS agent that pins the resolved IP: tracked for follow-up.
*/
export async function validateWebhookUrl(
rawUrl: string,
opts?: { resolve4?: typeof dns.resolve4; resolve6?: typeof dns.resolve6 },
): Promise<WebhookUrlValidationResult> {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return { ok: false, reason: 'invalid_url', detail: 'URL did not parse.' }
}
if (parsed.protocol !== 'https:') {
return {
ok: false,
reason: 'non_https_scheme',
detail: `webhook_url must use https:// (got ${parsed.protocol}).`,
}
}
const resolve4 = opts?.resolve4 ?? dns.resolve4
const resolve6 = opts?.resolve6 ?? dns.resolve6
// Resolve A and AAAA in parallel. Each returns an array of address
// strings or throws ENODATA / ENOTFOUND when there are no records of
// that family. Treat a per-family ENODATA as "no records" rather than
// a hard failure: the other family may still resolve.
const [v4Result, v6Result] = await Promise.allSettled([
resolve4(parsed.hostname),
resolve6(parsed.hostname),
])
const addresses: string[] = []
let hardFailure: Error | null = null
for (const r of [v4Result, v6Result]) {
if (r.status === 'fulfilled') {
addresses.push(...r.value)
} else {
const code = (r.reason as { code?: string } | null)?.code
// ENODATA / ENOTFOUND for one family is normal (e.g. v6-only or
// v4-only host). Other errors (server failure, timeout) propagate.
if (code !== 'ENODATA' && code !== 'ENOTFOUND') {
hardFailure = r.reason instanceof Error ? r.reason : new Error(String(r.reason))
}
}
}
if (addresses.length === 0) {
return {
ok: false,
reason: hardFailure ? 'dns_lookup_failed' : 'no_dns_records',
detail: hardFailure
? `DNS lookup failed for ${parsed.hostname}: ${hardFailure.message}`
: `No A/AAAA records for ${parsed.hostname}.`,
}
}
for (const address of addresses) {
const classification = classifyAddress(address)
if (classification !== 'public') {
return {
ok: false,
reason: classification,
detail: `Resolved address ${address} for ${parsed.hostname} is not publicly routable (${classification}).`,
}
}
}
return { ok: true, hostname: parsed.hostname, resolvedAddresses: addresses }
}
type AddressClass =
| 'public'
| 'loopback_address'
| 'private_address'
| 'link_local_address'
| 'cgnat_address'
| 'metadata_address'
/**
* Map an IPv4 or IPv6 address string to a safety class. Returns 'public'
* only when the address falls outside every known unsafe range we care
* about for SSRF prevention.
*/
function classifyAddress(address: string): AddressClass {
// IPv4
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address)
if (v4) {
const o = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10))
// Cloud metadata endpoint: explicit class so we surface it distinctly.
// 169.254.169.254 is AWS/GCP/Azure/Hetzner; classify before the broader
// 169.254.0.0/16 link-local check.
if (o[0] === 169 && o[1] === 254 && o[2] === 169 && o[3] === 254) {
return 'metadata_address'
}
if (o[0] === 169 && o[1] === 254) return 'link_local_address'
if (o[0] === 127) return 'loopback_address'
if (o[0] === 10) return 'private_address'
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return 'private_address'
if (o[0] === 192 && o[1] === 168) return 'private_address'
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return 'cgnat_address'
// 0.0.0.0/8: "this network", treat as loopback-equivalent.
if (o[0] === 0) return 'loopback_address'
return 'public'
}
// IPv6: minimal classification. Lower-case for case-insensitive match.
const v6 = address.toLowerCase()
if (v6 === '::1' || v6 === '0:0:0:0:0:0:0:1') return 'loopback_address'
if (v6 === '::' || v6 === '0:0:0:0:0:0:0:0') return 'loopback_address'
// ::ffff:0:0/96: IPv4-mapped IPv6. Re-classify the embedded IPv4.
const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(v6)
if (mapped) return classifyAddress(mapped[1])
// fc00::/7: unique local
if (/^f[cd]/.test(v6)) return 'private_address'
// fe80::/10: link-local
if (/^fe[89ab]/.test(v6)) return 'link_local_address'
return 'public'
}