Files
accounted/lib/webhooks/pinned-fetch.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

276 lines
10 KiB
TypeScript

/**
* Pinned-IP HTTPS POST for webhook dispatch.
*
* Closes the DNS-rebinding window between url-guard validation and the
* actual HTTPS request. The previous shape was:
*
* 1. validateWebhookUrl() → DNS resolves to [public IP], returns ok
* 2. fetch(webhook_url) → re-resolves DNS; an attacker who flipped
* the A record in the interval gets a
* private-IP socket
*
* The new shape pins the request to the IP validated in step 1, with the
* original hostname carried in:
* - the TLS SNI extension (so the receiver's cert continues to match)
* - the HTTP Host header (so vhost routing on the receiver continues to
* work)
*
* The request socket therefore never re-resolves DNS, foreclosing the
* rebind race. Documented openly per the url-guard.ts file header
* ("closing that requires a custom HTTPS agent that pins the resolved IP").
*
* Built on `node:https.request` rather than undici's Agent because (a) the
* project doesn't take a dependency on undici, (b) the stdlib API is more
* explicit about the SNI / Host / IP split, (c) https.request is enough
* for HTTP/1.1 + TLS, which every webhook receiver supports.
*
* Inversion seam: `httpsRequest` injectable for tests so we don't need to
* stand up an HTTPS server to verify the pinning / SNI / Host shape. The
* dispatcher's tests pass a stub through `pinnedFetchImpl`.
*/
import {
request as httpsRequestDefault,
type RequestOptions as HttpsRequestOptions,
} from 'node:https'
import type { ClientRequest, IncomingMessage } from 'node:http'
import { validateWebhookUrl as validateWebhookUrlDefault } from './url-guard'
export type PinnedFetchResult =
| {
kind: 'ok'
status: number
headers: Record<string, string>
body: string
bodyTruncated: boolean
pinnedAddress: string
}
| { kind: 'unsafe_url'; reason: string; detail: string; pinnedAddress: null }
| { kind: 'redirect_blocked'; status: number; detail: string; pinnedAddress: string }
| { kind: 'timeout'; detail: string; pinnedAddress: string }
| { kind: 'transport_error'; detail: string; pinnedAddress: string | null }
export interface PinnedFetchInit {
method: string
headers: Record<string, string>
body: string
timeoutMs: number
/** Max bytes captured from response body: receivers returning long error pages get truncated. */
maxResponseBytes: number
}
export interface PinnedFetchDeps {
/** DNS validation seam. Defaults to url-guard's validateWebhookUrl. */
validateUrl?: typeof validateWebhookUrlDefault
/** Raw HTTPS request seam. Defaults to node:https.request. */
httpsRequest?: (
options: HttpsRequestOptions,
callback: (res: IncomingMessage) => void,
) => ClientRequest
}
export async function pinnedHttpsFetch(
rawUrl: string,
init: PinnedFetchInit,
deps: PinnedFetchDeps = {},
): Promise<PinnedFetchResult> {
const validateUrl = deps.validateUrl ?? validateWebhookUrlDefault
const httpsRequest = deps.httpsRequest ?? httpsRequestDefault
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return {
kind: 'unsafe_url',
reason: 'invalid_url',
detail: 'URL did not parse.',
pinnedAddress: null,
}
}
const validation = await validateUrl(rawUrl)
if (!validation.ok) {
return {
kind: 'unsafe_url',
reason: validation.reason,
detail: validation.detail,
pinnedAddress: null,
}
}
// Pick the first vetted address. validateWebhookUrl rejects the whole
// set when ANY entry is unsafe, so the first is safe by construction.
// Deterministic choice keeps log output stable across retries.
const pinnedAddress = validation.resolvedAddresses[0]
if (!pinnedAddress) {
// Defensive: validateWebhookUrl returns ok only when there's at least
// one address, but a future refactor could regress this and we want
// the failure to be loud, not a silent DNS-lookup-by-empty-host.
return {
kind: 'transport_error',
detail: 'No resolved address from validateWebhookUrl',
pinnedAddress: null,
}
}
const port = parsed.port ? Number(parsed.port) : 443
return new Promise<PinnedFetchResult>((resolve) => {
let settled = false
const settle = (r: PinnedFetchResult) => {
if (settled) return
settled = true
resolve(r)
}
// The HTTP Host header must carry the original hostname (vhost routing
// on the receiver). Include the port only when non-default: RFC 7230
// §5.4 says the port is omitted when it matches the scheme default.
const hostHeader = port === 443 ? parsed.hostname : `${parsed.hostname}:${port}`
const requestOptions: HttpsRequestOptions = {
protocol: 'https:',
// Pin the socket to the validated IP. node:https accepts the
// address directly: no further DNS lookup happens.
host: pinnedAddress,
port,
path: parsed.pathname + parsed.search,
method: init.method,
// SNI carries the original hostname so the receiver's TLS cert
// (which is issued for the hostname, not the IP) validates.
//
// Cert-vs-hostname verification: Node's default checkServerIdentity
// matches the cert's SAN/CN against `servername` (or `host` when
// servername is unset). Because `servername` is set to the original
// hostname, the IP substitution above does NOT weaken the hostname-
// verification step: a forged endpoint at the pinned IP presenting
// a valid cert for a DIFFERENT hostname would fail the handshake.
// No explicit checkServerIdentity override is needed; relying on
// the default is the documented contract.
servername: parsed.hostname,
headers: {
...init.headers,
// Lowercase 'host': Node's https.request would synthesise one
// from `host` (the pinned IP) if we didn't set it explicitly,
// which would break vhost routing on the receiver.
host: hostHeader,
},
// Fresh socket per call: webhook delivery doesn't benefit from
// Keep-Alive (the dispatcher serializes and the IP changes per
// dispatch from re-validation). agent:false also forecloses any
// accidental pool-level reuse across pinned IPs.
agent: false,
}
let absoluteTimer: NodeJS.Timeout | null = null
const req = httpsRequest(requestOptions, (res) => {
// Receivers MUST return a non-redirect. Following a 3xx would let
// them bounce the dispatcher to a private address AFTER the SSRF
// guard cleared. We don't follow redirects; treat as terminal here
// and let the dispatcher mark the row dead with reason='redirect_
// blocked' for consistency with the old fetch path's behavior.
const status = res.statusCode ?? 0
if (status >= 300 && status < 400) {
// Drain body so the socket cleans up; ignore errors.
res.resume()
req.destroy()
if (absoluteTimer) clearTimeout(absoluteTimer)
return settle({
kind: 'redirect_blocked',
status,
detail: `Receiver returned ${status}; redirects are refused.`,
pinnedAddress,
})
}
const chunks: Buffer[] = []
let total = 0
let truncated = false
res.on('data', (chunk: Buffer) => {
if (truncated) return
if (total + chunk.length > init.maxResponseBytes) {
const remaining = init.maxResponseBytes - total
if (remaining > 0) chunks.push(chunk.subarray(0, remaining))
total = init.maxResponseBytes
truncated = true
// Destroy the stream: no point pulling the rest over the wire.
res.destroy()
} else {
chunks.push(chunk)
total += chunk.length
}
})
const finalize = () => {
if (absoluteTimer) clearTimeout(absoluteTimer)
const headers: Record<string, string> = {}
for (const [k, v] of Object.entries(res.headers)) {
if (typeof v === 'string') headers[k] = v
else if (Array.isArray(v)) headers[k] = v.join(', ')
}
settle({
kind: 'ok',
status,
headers,
body: Buffer.concat(chunks).toString('utf8'),
bodyTruncated: truncated,
pinnedAddress,
})
}
// Two completion paths to handle: 'end' (normal completion) and
// 'close' (when we destroyed the stream for size truncation, where
// 'end' does not fire). Node emits BOTH 'end' and 'close' on normal
// completions, so `once()` + a self-removing pair keeps finalize
// single-shot without relying on the outer `settled` guard to
// squash duplicate header reconstruction.
const finalizeOnce = () => {
res.removeListener('end', finalizeOnce)
res.removeListener('close', finalizeOnce)
finalize()
}
res.once('end', finalizeOnce)
res.once('close', finalizeOnce)
res.on('error', (err) => {
if (absoluteTimer) clearTimeout(absoluteTimer)
settle({ kind: 'transport_error', detail: err.message, pinnedAddress })
})
})
// Two-layer timeout: socket-idle timeout via Node's built-in, plus a
// wall-clock absolute timeout. node:https `timeout` is idle-only and
// wouldn't fire if a slow receiver dribbles bytes; the absolute timer
// is the hard cap.
req.setTimeout(init.timeoutMs)
req.on('timeout', () => {
req.destroy()
if (absoluteTimer) clearTimeout(absoluteTimer)
settle({
kind: 'timeout',
detail: `Socket idle for ${init.timeoutMs} ms`,
pinnedAddress,
})
})
absoluteTimer = setTimeout(() => {
req.destroy()
settle({
kind: 'timeout',
detail: `Request exceeded ${init.timeoutMs} ms wall-clock`,
pinnedAddress,
})
}, init.timeoutMs)
req.on('error', (err) => {
if (absoluteTimer) clearTimeout(absoluteTimer)
settle({ kind: 'transport_error', detail: err.message, pinnedAddress })
})
if (init.body) req.write(init.body)
req.end()
})
}