@kyaki/witness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/accountability.d.ts +43 -0
  2. package/dist/accountability.d.ts.map +1 -0
  3. package/dist/accountability.js +64 -0
  4. package/dist/accountability.js.map +1 -0
  5. package/dist/adapters/monitor-http.d.ts +31 -0
  6. package/dist/adapters/monitor-http.d.ts.map +1 -0
  7. package/dist/adapters/monitor-http.js +59 -0
  8. package/dist/adapters/monitor-http.js.map +1 -0
  9. package/dist/adapters/witness-http.d.ts +62 -0
  10. package/dist/adapters/witness-http.d.ts.map +1 -0
  11. package/dist/adapters/witness-http.js +212 -0
  12. package/dist/adapters/witness-http.js.map +1 -0
  13. package/dist/durable-witness.d.ts +101 -0
  14. package/dist/durable-witness.d.ts.map +1 -0
  15. package/dist/durable-witness.js +179 -0
  16. package/dist/durable-witness.js.map +1 -0
  17. package/dist/fan-out.d.ts +20 -0
  18. package/dist/fan-out.d.ts.map +1 -0
  19. package/dist/fan-out.js +30 -0
  20. package/dist/fan-out.js.map +1 -0
  21. package/dist/index.d.ts +17 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +17 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/monitor.d.ts +83 -0
  26. package/dist/monitor.d.ts.map +1 -0
  27. package/dist/monitor.js +133 -0
  28. package/dist/monitor.js.map +1 -0
  29. package/dist/pollination.d.ts +109 -0
  30. package/dist/pollination.d.ts.map +1 -0
  31. package/dist/pollination.js +170 -0
  32. package/dist/pollination.js.map +1 -0
  33. package/dist/ssrf.d.ts +20 -0
  34. package/dist/ssrf.d.ts.map +1 -0
  35. package/dist/ssrf.js +205 -0
  36. package/dist/ssrf.js.map +1 -0
  37. package/package.json +33 -0
  38. package/src/accountability.ts +72 -0
  39. package/src/adapters/monitor-http.ts +69 -0
  40. package/src/adapters/witness-http.ts +235 -0
  41. package/src/durable-witness.ts +190 -0
  42. package/src/fan-out.ts +34 -0
  43. package/src/index.ts +28 -0
  44. package/src/monitor.ts +159 -0
  45. package/src/pollination.ts +229 -0
  46. package/src/ssrf.ts +190 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * pollination.ts — relying-party STH pollination (Phase 6).
3
+ *
4
+ * The one sound defense against a TARGETED split-view — an operator that shows a victim a
5
+ * root B@n it never shows any honest witness. The monitor cannot see B (it never enters the
6
+ * witnessed world), and quorum verification over the operator's OWN co-signature bundle can
7
+ * be cherry-picked by the operator. Pollination closes it by having the VICTIM pull each
8
+ * trusted witness's co-signature DIRECTLY (operator-independent, SSRF-safe) and run the
9
+ * audited `verifyWitnessedTreeHead`, FAIL-CLOSED: unless >= quorum distinct trusted witnesses
10
+ * co-signed the EXACT served root, the victim refuses.
11
+ *
12
+ * Why it is sound: a witness co-signature is WITNESS-signed over (logId, treeSize, rootHash),
13
+ * so neither a MITM nor the operator can forge "W endorsed B". The victim asks the witnesses
14
+ * themselves, so the operator cannot turn suppression into a false ACCEPT — if it withholds the
15
+ * witness responses the victim simply FAILS CLOSED (detection of the contradiction requires the
16
+ * victim to actually reach >= quorum honest witnesses directly). It does NOT defeat COLLUSION
17
+ * (>= quorum witnesses lying together — quorum >= 2 under disjoint control remains the only
18
+ * in-band lever). And SPENDS NEVER DEPEND ON WITNESS LIVENESS: pollination is an ADDITIONAL
19
+ * relying-party fail-closed check layered on quorum, not the kernel's safety gate; an
20
+ * unreachable witness is reported and simply does not count toward the quorum.
21
+ *
22
+ * DISPLAY ↔ VERDICT (load-bearing, do not weaken): `confirmed` is taken SOLELY from the audited
23
+ * `verifyWitnessedTreeHead` — never a hand-ported quorum rule. The display fields
24
+ * (matched/revoked/unreachable) are a MIRROR of that verdict's own accounting, gated on the
25
+ * SAME pre-count refusals the verifier applies (the policy.logId pin, the quorum-validity bound,
26
+ * and STH authenticity). So `matched` can never be reported under a basis the verdict already
27
+ * rejected — `matched >= quorum` if and only if `confirmed` (see the gate below). The two were
28
+ * once allowed to diverge on a malformed policy (a §4 over-claim caught by the Phase-6 review);
29
+ * that is now closed by computing the display under the verifier's exact gates.
30
+ */
31
+ import {
32
+ verifyWitnessedTreeHead, verifyWitnessCosignature, verifySignedTreeHead,
33
+ type SignedTreeHead, type WitnessCosignature, type WitnessedTreeHead, type WitnessPolicy,
34
+ } from '@kyaki/core';
35
+ import { boundedFanOut } from './fan-out.js';
36
+ import { safeFetchJson, type SsrfPolicy } from './ssrf.js';
37
+
38
+ /** A trusted witness a relying party pulls a co-signature from. Satisfied in-process by
39
+ * `DurableWitness` (it has `id` + `endorsement`) and over the wire by `HttpPollinationSource`.
40
+ * `id` SHOULD be set (both built-in implementations set it): a source may only contribute the
41
+ * endorsement of the witness it claims to be — see the identity binding in `pollinate`. */
42
+ export interface PollinationSource {
43
+ /** The witness DID — must be in the relying party's `trustedWitnesses` to count, and must
44
+ * match the `witnessId` of the co-signature this source returns. */
45
+ readonly id?: string;
46
+ /** This witness's endorsement (its endorsed STH + its own co-signature) at `treeSize`.
47
+ * MUST be self-bounding (its own timeout) for a hard liveness guarantee; `pollinate` also
48
+ * applies a backstop per-source timeout so a hung source can never wedge the whole call. */
49
+ endorsement(treeSize?: number): Promise<{ sth: SignedTreeHead; cosignature: WitnessCosignature } | undefined>;
50
+ }
51
+
52
+ export interface PollinationResult {
53
+ /** Fail-closed verdict: did >= quorum DISTINCT trusted, non-revoked witnesses co-sign the
54
+ * EXACT (logId, treeSize, rootHash) served? Driven SOLELY by `verifyWitnessedTreeHead`.
55
+ *
56
+ * What `confirmed=true` MEANS — and does NOT: a quorum of the relying party's OWN trusted set
57
+ * co-signed the exact served root. It is NOT proof of honesty: COLLUSION of >= quorum trusted
58
+ * witnesses is out of scope (raise the bar only with quorum >= 2 under disjoint control, or
59
+ * threshold signatures). `confirmed=false` with witnesses `unreachable` is INCONCLUSIVE
60
+ * (a liveness gap), not proof of a fork — distinguish it from `confirmed=false` with all
61
+ * witnesses reached (corroboration genuinely refused). */
62
+ confirmed: boolean;
63
+ /** Distinct trusted, NON-REVOKED witnesses whose co-signature authentically binds the served head.
64
+ * Counted exactly as the verdict counts — a revoked witness is never included, and on any input
65
+ * the verdict rejects pre-count (wrong operator / invalid quorum / non-authentic STH) `matched`
66
+ * is 0, so it can never contradict `confirmed` (e.g. show matched >= quorum while confirmed=false). */
67
+ matched: number;
68
+ /** Size of the trusted-witness set (the ORIGINAL allowlist; `revoked` is reported separately so the
69
+ * UI can render "N of T, R revoked" transparently rather than silently shrinking the denominator). */
70
+ total: number;
71
+ /** Distinct trusted witnesses whose co-signature bound the head but were NOT counted because they
72
+ * are revoked (Phase 7 v1b) — a transparency signal, never part of the quorum. */
73
+ revoked: number;
74
+ /** Distinct TRUSTED witnesses that could not be reached and did not end up matched or revoked —
75
+ * a LIVENESS signal (they never count toward the quorum). Keyed to the trusted set and excludes
76
+ * any witness already counted, so matched + revoked + unreachable <= total (a coherent partition);
77
+ * a non-trusted source that failed is not surfaced here (it could never have counted anyway). */
78
+ unreachable: string[];
79
+ }
80
+
81
+ /** Optional knobs for `pollinate`. */
82
+ export interface PollinateOptions {
83
+ /** Backstop timeout (ms) applied to EACH source pull, so a hung source (e.g. an in-process
84
+ * durable witness blocked on a store lock) can never wedge the whole call — the "worst-case
85
+ * one timeout" liveness guarantee is a property of `pollinate`, not delegated to the source
86
+ * type. Set above an HTTP source's own SSRF timeout so it only bites a genuinely hung source.
87
+ * Default 10000. */
88
+ sourceTimeoutMs?: number;
89
+ }
90
+
91
+ const DEFAULT_SOURCE_TIMEOUT_MS = 10_000;
92
+
93
+ /** Resolve `p`, or reject with POLLINATION_SOURCE_TIMEOUT after `ms`. The underlying work may
94
+ * continue (an in-process op is not cancellable) but its result is ignored — what is bounded is
95
+ * how long the caller WAITS, which is the liveness property that matters. ms<=0 disables the bound. */
96
+ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
97
+ if (!Number.isFinite(ms) || ms <= 0) return p;
98
+ return new Promise<T>((resolve, reject) => {
99
+ const timer = setTimeout(() => reject(new Error('POLLINATION_SOURCE_TIMEOUT')), ms);
100
+ p.then(
101
+ (v) => { clearTimeout(timer); resolve(v); },
102
+ (e) => { clearTimeout(timer); reject(e); },
103
+ );
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Pollinate: pull each trusted witness's co-signature at the served head's size and confirm,
109
+ * fail-closed, that >= quorum distinct trusted witnesses co-signed the EXACT (logId, treeSize,
110
+ * rootHash) the relying party was served. The verdict is the audited `verifyWitnessedTreeHead`
111
+ * (never a hand-ported quorum rule). An unreachable witness is reported and never makes the
112
+ * check pass. Liveness-independent: the fan-out is concurrency-bounded and per-source
113
+ * timeout-bounded, so neither a large pinned witness set nor a hung source can DoS the victim,
114
+ * and a pollination failure never blocks a spend (it is an additional relying-party check).
115
+ */
116
+ export async function pollinate(
117
+ servedSth: SignedTreeHead,
118
+ sources: readonly PollinationSource[],
119
+ policy: WitnessPolicy,
120
+ opts?: PollinateOptions,
121
+ ): Promise<PollinationResult> {
122
+ const trusted = new Set(policy.trustedWitnesses ?? []);
123
+ const revoked = new Set(policy.revokedWitnesses ?? []); // Phase 7 v1b — proven equivocators
124
+ const timeoutMs = opts?.sourceTimeoutMs ?? DEFAULT_SOURCE_TIMEOUT_MS;
125
+
126
+ // Guard the served size at the boundary: a non-integer/negative treeSize can only come from a
127
+ // malformed STH (which `verifyWitnessedTreeHead` rejects anyway) and would build a junk
128
+ // `?size=NaN` query. Skip the fetch entirely in that case — but STILL derive `confirmed` from
129
+ // the audited verifier below (never hand-decide it).
130
+ const validSize = Number.isInteger(servedSth.treeSize) && servedSth.treeSize >= 0;
131
+
132
+ const cosignatures: WitnessCosignature[] = [];
133
+ const downLabels: string[] = [];
134
+ if (validSize) {
135
+ // Pull every source with a BOUNDED in-flight cap (a large pinned set can't exhaust sockets/heap)
136
+ // and a per-source backstop timeout. Each branch catches its OWN failure — including a null/
137
+ // malformed source entry (the label is computed defensively) — so a dead source lands in
138
+ // `unreachable` and never rejects the whole call. Worst-case wall-clock is one timeout per batch.
139
+ const settled = await boundedFanOut(
140
+ sources,
141
+ async (src, i): Promise<{ cosig: WitnessCosignature } | { down: string }> => {
142
+ const label = src?.id ?? `witness#${i}`;
143
+ try {
144
+ const e = await withTimeout(src.endorsement(servedSth.treeSize), timeoutMs);
145
+ const cosig = e?.cosignature;
146
+ if (!cosig) return { down: label }; // no endorsement ⇒ cannot corroborate
147
+ // Bind the source's claimed identity to the co-signature it returns: a source may only
148
+ // ever contribute its OWN endorsement, so a single (possibly operator-controlled) endpoint
149
+ // cannot stand in for the quorum by replaying several witnesses' public co-signatures — the
150
+ // operator-INDEPENDENCE the mechanism rests on. A mismatch ⇒ this source did not return its
151
+ // own endorsement ⇒ treat it as no corroboration (it never counts toward quorum).
152
+ if (src?.id !== undefined && cosig.witnessId !== src.id) return { down: label };
153
+ return { cosig };
154
+ } catch {
155
+ return { down: label }; // transport failure / timeout ⇒ liveness
156
+ }
157
+ },
158
+ );
159
+ for (const r of settled) {
160
+ if (r.status !== 'fulfilled') continue; // boundedFanOut settles per item and never throws; defensive
161
+ if ('cosig' in r.value) cosignatures.push(r.value.cosig);
162
+ else downLabels.push(r.value.down);
163
+ }
164
+ }
165
+
166
+ const wth: WitnessedTreeHead = { sth: servedSth, cosignatures };
167
+ const confirmed = verifyWitnessedTreeHead(wth, policy); // the AUTHORITATIVE fail-closed verdict
168
+
169
+ // DISPLAY-NEVER-CONTRADICTS-VERDICT. The display fields must mirror EXACTLY what the verdict
170
+ // counts. `verifyWitnessedTreeHead` refuses BEFORE counting any endorser when the served head is
171
+ // not the pinned operator's authentic STH (its logId != policy.logId, or the operator signature is
172
+ // invalid), the trusted set is empty, or the quorum is structurally invalid. On ANY of those the
173
+ // honest display is "0 corroborated" — never a parallel count under a basis the verdict already
174
+ // rejected. Mirror those exact pre-count gates (transparency.ts verifyWitnessedTreeHead) here.
175
+ const quorum = policy.quorum ?? trusted.size;
176
+ const quorumValid = Number.isInteger(quorum) && quorum >= 1 && quorum <= trusted.size;
177
+ const structurallyValid =
178
+ validSize
179
+ && trusted.size > 0
180
+ && quorumValid
181
+ && servedSth.logId === policy.logId // the policy.logId PIN — NOT the served head's own logId
182
+ && verifySignedTreeHead(servedSth); // authentic operator STH (catches a forged sig over a real root)
183
+ if (!structurallyValid) {
184
+ return { confirmed, matched: 0, total: trusted.size, revoked: 0, unreachable: trustedDistinct(downLabels, trusted) };
185
+ }
186
+
187
+ // On the structurally-valid path these per-cosig filters are byte-identical to
188
+ // `verifyWitnessedTreeHead`'s (self-witness exclusion, trusted membership, exact tuple, authentic
189
+ // cosig, revocation skip, distinct-by-witnessId), so `endorsers.size === the verifier's count` and
190
+ // `matched >= quorum <=> confirmed`. This is a DISPLAY mirror of the verdict, never a substitute.
191
+ const endorsers = new Set<string>();
192
+ const revokedSeen = new Set<string>(); // DISTINCT revoked witnesses that bound the head (never counted)
193
+ for (const c of cosignatures) {
194
+ if (!c || c.witnessId === policy.logId) continue; // no operator self-witness (policy.logId == servedSth.logId here)
195
+ if (!trusted.has(c.witnessId)) continue;
196
+ if (c.logId !== policy.logId || c.treeSize !== servedSth.treeSize || c.rootHash !== servedSth.rootHash) continue;
197
+ if (!verifyWitnessCosignature(c)) continue;
198
+ if (revoked.has(c.witnessId)) { revokedSeen.add(c.witnessId); continue; } // bound the head, but NOT counted (v1b)
199
+ endorsers.add(c.witnessId);
200
+ }
201
+ // `unreachable`: distinct TRUSTED witnesses whose source(s) failed, EXCLUDING any that ended up
202
+ // matched or revoked (a witness reachable on one endpoint and down on another is NOT "unreachable").
203
+ // Keyed to the trusted set so matched + revoked + unreachable <= total — a coherent partition.
204
+ const unreachable = [...new Set(downLabels)].filter(
205
+ (id) => trusted.has(id) && !endorsers.has(id) && !revokedSeen.has(id),
206
+ );
207
+ return { confirmed, matched: endorsers.size, total: trusted.size, revoked: revokedSeen.size, unreachable };
208
+ }
209
+
210
+ /** Distinct TRUSTED labels from a down-source list — the `unreachable` field on a structurally
211
+ * refused head (no witness can have counted, so there is nothing to exclude). */
212
+ function trustedDistinct(labels: readonly string[], trusted: ReadonlySet<string>): string[] {
213
+ return [...new Set(labels)].filter((id) => trusted.has(id));
214
+ }
215
+
216
+ /** Pulls a witness's endorsement over the wire via `GET /cosig?size`, SSRF-guarded.
217
+ * The load-bearing object is the returned `cosignature` (witness-signed over the exact tuple);
218
+ * the returned `sth` is informational and not re-trusted by `pollinate`. */
219
+ export class HttpPollinationSource implements PollinationSource {
220
+ constructor(private readonly baseUrl: string, readonly id: string, private readonly ssrf: SsrfPolicy) {}
221
+
222
+ async endorsement(treeSize?: number): Promise<{ sth: SignedTreeHead; cosignature: WitnessCosignature } | undefined> {
223
+ const url = treeSize === undefined ? `${this.baseUrl}/cosig` : `${this.baseUrl}/cosig?size=${treeSize}`;
224
+ const body = (await safeFetchJson(url, { method: 'GET' }, this.ssrf)) as
225
+ { sth?: SignedTreeHead | null; cosignature?: WitnessCosignature | null } | undefined;
226
+ if (!body?.sth || !body.cosignature) return undefined;
227
+ return { sth: body.sth, cosignature: body.cosignature };
228
+ }
229
+ }
package/src/ssrf.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * ssrf.ts — an SSRF-safe outbound fetch for the WitnessMonitor (RULES §5.3).
3
+ *
4
+ * The monitor PULLS operator-signed STHs from witness URLs. Left unguarded, a witness
5
+ * base URL of `http://169.254.169.254/` (cloud metadata) or `http://localhost:5432`
6
+ * (an internal service) turns the monitor into a server-side request forgery primitive.
7
+ * §0 ranks security above correctness, so this is a build-blocking control, not a
8
+ * deployment afterthought. The layered defense:
9
+ * 1. HOST ALLOWLIST — the monitor may only connect to the hostnames of its
10
+ * CONFIG-PINNED witness set; a request to any other host is refused. (The witness
11
+ * set is operator-INDEPENDENT and not attacker-registerable — that is the whole
12
+ * basis of the cross-party guarantee, so pinning it here is free.)
13
+ * 2. SCHEME — https only, unless `allowHttp` is set for loopback dev/tests.
14
+ * 3. NO INTERNAL TARGETS — reject IP-literal hosts in loopback/RFC1918/link-local/ULA
15
+ * ranges, AND resolve hostnames and reject any answer in those ranges
16
+ * (resolve-then-check defeats a DNS-rebinding allowlisted name pointing inward).
17
+ * 4. NO REDIRECTS — `redirect: 'error'` so a 3xx to an internal target cannot smuggle
18
+ * past the checks; BODY CAP + TIMEOUT so a hostile/slow endpoint cannot exhaust
19
+ * heap or wedge a polling round.
20
+ *
21
+ * Residual (documented): a TOCTOU between resolve-check and connect remains (the OS may
22
+ * re-resolve to a different IP). The host allowlist is the primary control; full IP
23
+ * pinning of the connection is the further hardening if the monitor is ever pointed at
24
+ * untrusted-DNS hosts. For a pinned, operator-independent witness set this is sufficient.
25
+ */
26
+ import { lookup } from 'node:dns/promises';
27
+ import { isIP } from 'node:net';
28
+
29
+ export interface SsrfPolicy {
30
+ /** Exact hostnames (lowercased) the monitor may connect to — the pinned witness set.
31
+ * REQUIRED and non-empty: an empty allowlist refuses everything (fail-closed). */
32
+ allowHosts: string[];
33
+ /** Permit plain http and loopback/private targets — loopback dev & tests ONLY.
34
+ * Default false ⇒ https required and every internal range is refused. */
35
+ allowHttp?: boolean;
36
+ /** Max response body bytes. Default 64 KiB (an STH/proof is well under 1 KiB). */
37
+ maxBytes?: number;
38
+ /** Per-request timeout in ms. Default 5000. */
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ const DEFAULT_MAX_BYTES = 64 * 1024;
43
+ const DEFAULT_TIMEOUT_MS = 5000;
44
+
45
+ /** Map a pair of 16-bit hex groups (the low 32 bits of a v6 address) to dotted v4. */
46
+ function hexPairToV4(hiHex: string, loHex: string): string {
47
+ const hi = parseInt(hiHex, 16), lo = parseInt(loHex, 16);
48
+ return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
49
+ }
50
+
51
+ /** Is `ip` (a valid v4/v6 literal) in a loopback/private/link-local/ULA/CGNAT/NAT64 range? */
52
+ export function isPrivateIp(ip: string): boolean {
53
+ const v = isIP(ip);
54
+ if (v === 4) {
55
+ const p = ip.split('.').map(Number);
56
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; // malformed ⇒ unsafe
57
+ const [a, b] = p as [number, number, number, number];
58
+ if (a === 10) return true; // 10.0.0.0/8
59
+ if (a === 127) return true; // loopback
60
+ if (a === 0) return true; // 0.0.0.0/8 unspecified
61
+ if (a === 169 && b === 254) return true; // link-local
62
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
63
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
64
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 carrier-grade NAT (RFC 6598)
65
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
66
+ if (a >= 224) return true; // multicast / reserved
67
+ return false;
68
+ }
69
+ if (v === 6) {
70
+ const ip6 = ip.toLowerCase().replace(/^\[|\]$/g, '');
71
+ if (ip6 === '::1' || ip6 === '::') return true; // loopback / unspecified
72
+ if (ip6.startsWith('fe80')) return true; // link-local
73
+ if (ip6.startsWith('fc') || ip6.startsWith('fd')) return true; // ULA fc00::/7
74
+ // IPv4-mapped (::ffff:a.b.c.d AND its hex-quad form ::ffff:7f00:1) — re-check the v4.
75
+ const mapped = ip6.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
76
+ if (mapped) return isPrivateIp(mapped[1]!);
77
+ const hexMapped = ip6.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
78
+ if (hexMapped) return isPrivateIp(hexPairToV4(hexMapped[1]!, hexMapped[2]!));
79
+ // NAT64 well-known prefix (64:ff9b::/96) embeds an arbitrary v4 destination — a gateway
80
+ // forwards 64:ff9b::7f00:1 to 127.0.0.1, so the embedded v4 must be re-checked too.
81
+ const nat64Dotted = ip6.match(/^64:ff9b::(\d+\.\d+\.\d+\.\d+)$/);
82
+ if (nat64Dotted) return isPrivateIp(nat64Dotted[1]!);
83
+ const nat64Hex = ip6.match(/^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
84
+ if (nat64Hex) return isPrivateIp(hexPairToV4(nat64Hex[1]!, nat64Hex[2]!));
85
+ if (ip6 === '64:ff9b::') return true; // the bare NAT64 prefix
86
+ return false;
87
+ }
88
+ return true; // not a recognizable IP ⇒ treat as unsafe
89
+ }
90
+
91
+ /** Validate a URL against the policy; throws a named `SSRF_*` error on any violation. */
92
+ export async function assertSafeUrl(rawUrl: string, policy: SsrfPolicy): Promise<void> {
93
+ let url: URL;
94
+ try {
95
+ url = new URL(rawUrl);
96
+ } catch {
97
+ throw new Error('SSRF_INVALID_URL');
98
+ }
99
+ const allowHttp = policy.allowHttp === true;
100
+ if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:')) {
101
+ throw new Error(`SSRF_SCHEME_FORBIDDEN: ${url.protocol}`);
102
+ }
103
+ const host = url.hostname.toLowerCase();
104
+ const allow = new Set((policy.allowHosts ?? []).map((h) => h.toLowerCase()));
105
+ if (allow.size === 0 || !allow.has(host)) {
106
+ throw new Error(`SSRF_HOST_NOT_ALLOWED: ${host}`);
107
+ }
108
+ // An IP-literal host: check the literal directly.
109
+ if (isIP(host)) {
110
+ if (isPrivateIp(host) && !allowHttp) throw new Error(`SSRF_PRIVATE_IP: ${host}`);
111
+ return;
112
+ }
113
+ // A hostname: resolve and reject any answer in an internal range (DNS-rebinding guard).
114
+ if (!allowHttp) {
115
+ let addrs: { address: string }[];
116
+ try {
117
+ addrs = await lookup(host, { all: true });
118
+ } catch {
119
+ throw new Error(`SSRF_DNS_FAILED: ${host}`);
120
+ }
121
+ if (addrs.length === 0) throw new Error(`SSRF_DNS_EMPTY: ${host}`);
122
+ for (const { address } of addrs) {
123
+ if (isPrivateIp(address)) throw new Error(`SSRF_RESOLVES_PRIVATE: ${host} -> ${address}`);
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Read a response body, aborting the moment the running byte total exceeds `maxBytes`.
130
+ * NEVER trusts Content-Length (a hostile/chunked endpoint omits it): the cap is enforced
131
+ * while STREAMING, so heap is bounded to maxBytes + one chunk regardless of the header or
132
+ * transfer encoding. `res.text()` would buffer the whole body BEFORE any size check — the
133
+ * exact DoS this avoids.
134
+ */
135
+ async function readCappedText(res: Response, maxBytes: number, controller: AbortController): Promise<string> {
136
+ const reader = res.body?.getReader();
137
+ if (!reader) { // no stream available — still cap, never unbounded
138
+ const buf = Buffer.from(await res.arrayBuffer());
139
+ if (buf.length > maxBytes) throw new Error(`SSRF_BODY_TOO_LARGE: > ${maxBytes}`);
140
+ return buf.toString('utf8');
141
+ }
142
+ const chunks: Uint8Array[] = [];
143
+ let total = 0;
144
+ try {
145
+ for (;;) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+ if (!value) continue;
149
+ total += value.byteLength;
150
+ if (total > maxBytes) {
151
+ controller.abort(); // stop the transfer immediately
152
+ throw new Error(`SSRF_BODY_TOO_LARGE: > ${maxBytes}`);
153
+ }
154
+ chunks.push(value);
155
+ }
156
+ } finally {
157
+ try { reader.releaseLock(); } catch { /* already released on abort */ }
158
+ }
159
+ return Buffer.concat(chunks).toString('utf8');
160
+ }
161
+
162
+ /** A fetch that enforces the SSRF policy, blocks redirects, and caps body + time.
163
+ * Returns the parsed JSON body (or throws on a non-2xx / oversize / timeout). */
164
+ export async function safeFetchJson(rawUrl: string, init: RequestInit, policy: SsrfPolicy): Promise<unknown> {
165
+ const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES;
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(), policy.timeoutMs ?? DEFAULT_TIMEOUT_MS);
168
+ try {
169
+ // The DNS resolve inside assertSafeUrl is NOT abortable (node:dns/promises.lookup ignores
170
+ // AbortSignal), so race it against the SAME timeout — otherwise a slow-resolving (but
171
+ // allowlisted) host blows past the per-request ceiling before fetch even starts.
172
+ const aborted = new Promise<never>((_, reject) => {
173
+ controller.signal.addEventListener('abort', () => reject(new Error('SSRF_TIMEOUT')), { once: true });
174
+ });
175
+ await Promise.race([assertSafeUrl(rawUrl, policy), aborted]);
176
+ const res = await fetch(rawUrl, { ...init, redirect: 'error', signal: controller.signal });
177
+ if (res.status === 204) return undefined;
178
+ if (!res.ok) throw new Error(`HTTP_${res.status}`);
179
+ // A declared oversized Content-Length is a cheap fast-fail; but a MISSING/zero header is
180
+ // "unknown size", NOT "safe" — the streaming reader enforces the real cap either way.
181
+ const declared = Number(res.headers.get('content-length'));
182
+ if (Number.isFinite(declared) && declared > maxBytes) {
183
+ throw new Error(`SSRF_BODY_TOO_LARGE: ${declared} > ${maxBytes}`);
184
+ }
185
+ const text = await readCappedText(res, maxBytes, controller);
186
+ return text ? JSON.parse(text) : undefined;
187
+ } finally {
188
+ clearTimeout(timer);
189
+ }
190
+ }