Files
accounted/lib/webhooks/url-guard.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

187 lines
6.8 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'
}
export const __TESTING__ = { classifyAddress }