f266c386f3
* 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>
185 lines
6.7 KiB
TypeScript
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'
|
|
}
|