@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.
- package/dist/accountability.d.ts +43 -0
- package/dist/accountability.d.ts.map +1 -0
- package/dist/accountability.js +64 -0
- package/dist/accountability.js.map +1 -0
- package/dist/adapters/monitor-http.d.ts +31 -0
- package/dist/adapters/monitor-http.d.ts.map +1 -0
- package/dist/adapters/monitor-http.js +59 -0
- package/dist/adapters/monitor-http.js.map +1 -0
- package/dist/adapters/witness-http.d.ts +62 -0
- package/dist/adapters/witness-http.d.ts.map +1 -0
- package/dist/adapters/witness-http.js +212 -0
- package/dist/adapters/witness-http.js.map +1 -0
- package/dist/durable-witness.d.ts +101 -0
- package/dist/durable-witness.d.ts.map +1 -0
- package/dist/durable-witness.js +179 -0
- package/dist/durable-witness.js.map +1 -0
- package/dist/fan-out.d.ts +20 -0
- package/dist/fan-out.d.ts.map +1 -0
- package/dist/fan-out.js +30 -0
- package/dist/fan-out.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/monitor.d.ts +83 -0
- package/dist/monitor.d.ts.map +1 -0
- package/dist/monitor.js +133 -0
- package/dist/monitor.js.map +1 -0
- package/dist/pollination.d.ts +109 -0
- package/dist/pollination.d.ts.map +1 -0
- package/dist/pollination.js +170 -0
- package/dist/pollination.js.map +1 -0
- package/dist/ssrf.d.ts +20 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +205 -0
- package/dist/ssrf.js.map +1 -0
- package/package.json +33 -0
- package/src/accountability.ts +72 -0
- package/src/adapters/monitor-http.ts +69 -0
- package/src/adapters/witness-http.ts +235 -0
- package/src/durable-witness.ts +190 -0
- package/src/fan-out.ts +34 -0
- package/src/index.ts +28 -0
- package/src/monitor.ts +159 -0
- package/src/pollination.ts +229 -0
- 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
|
+
}
|