haechi 1.0.0 → 1.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.
@@ -0,0 +1,189 @@
1
+ // Core SSRF guard (Haechi 1.1 §2.3) — a node:-only, zero-dependency home for the
2
+ // address-blocklist + guarded-fetch pattern so CORE code (the process-isolated
3
+ // host-mediated key fetch) can use it. Core cannot import from a satellite, which
4
+ // is why this lives here.
5
+ //
6
+ // NOTE on the satellites: haechi-auth-jwt exports `isBlockedAddress`, and
7
+ // haechi-crypto-kms (vault.mjs) keeps a DELIBERATE satellite-local copy — a
8
+ // crypto/key-custody package must not runtime-depend on an auth (or core-ssrf)
9
+ // module's availability (see satellites/crypto-kms/ssrf-parity.test.mjs). 1.1 does
10
+ // NOT force those satellites to re-import this module (that would raise their
11
+ // `haechi` peer floor to 1.1 and republish them); instead the range logic here is
12
+ // kept byte-for-behavior identical to the satellite copies and guarded by a parity
13
+ // test (tests/ssrf.test.mjs). The drift is guarded, not (yet) eliminated.
14
+
15
+ import { isIP } from "node:net";
16
+ import { lookup as dnsLookup } from "node:dns/promises";
17
+
18
+ const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
19
+ const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
20
+
21
+ // Block literal addresses in private/loopback/link-local ranges + cloud metadata.
22
+ // Applied to both a literal host in the URL and every DNS-resolved address. This
23
+ // is the canonical copy; the satellite copies must agree (parity-tested).
24
+ export function isBlockedAddress(host) {
25
+ // A URL's .hostname keeps the brackets on an IPv6 literal ("[::1]"), and isIP
26
+ // rejects a bracketed string — strip them first so literals are classified.
27
+ const bare = String(host).replace(/^\[|\]$/g, "");
28
+ const v = isIP(bare);
29
+ if (v === 4) {
30
+ const o = bare.split(".").map(Number);
31
+ if (o[0] === 127) return true; // 127.0.0.0/8 loopback
32
+ if (o[0] === 10) return true; // 10.0.0.0/8
33
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 172.16/12
34
+ if (o[0] === 192 && o[1] === 168) return true; // 192.168/16
35
+ if (o[0] === 169 && o[1] === 254) return true; // 169.254/16 link-local incl. metadata
36
+ if (o[0] === 0) return true; // 0.0.0.0/8
37
+ return false;
38
+ }
39
+ if (v === 6) {
40
+ const h = bare.toLowerCase();
41
+ if (h === "::1" || h === "::") return true; // loopback / unspecified
42
+ if (h.startsWith("::ffff:")) { // IPv4-mapped
43
+ const mapped = h.slice("::ffff:".length);
44
+ if (isIP(mapped) === 4) return isBlockedAddress(mapped);
45
+ }
46
+ // Range-check the first hextet: fe80::/10 link-local, fc00::/7 ULA, ff00::/8 multicast.
47
+ const firstHextet = parseInt(h.split(":")[0] || "", 16);
48
+ if (Number.isFinite(firstHextet)) {
49
+ if (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) return true; // link-local
50
+ if (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) return true; // unique local
51
+ if (firstHextet >= 0xff00 && firstHextet <= 0xffff) return true; // multicast
52
+ }
53
+ return false;
54
+ }
55
+ return false; // not a literal IP; resolved addresses are checked separately
56
+ }
57
+
58
+ function parseHttpsUrl(value, label) {
59
+ let url;
60
+ try {
61
+ url = new URL(value);
62
+ } catch {
63
+ throw new Error(`${label} must be a valid URL`);
64
+ }
65
+ if (url.protocol !== "https:") {
66
+ throw new Error(`${label} must be https`);
67
+ }
68
+ return url;
69
+ }
70
+
71
+ // HTTPS-only, SSRF-hardened fetch returning the response body TEXT (bounded):
72
+ // - https only;
73
+ // - the literal host AND every DNS-resolved address must pass isBlockedAddress
74
+ // (post-DNS re-check catches a hostname mapping to a private/metadata IP);
75
+ // - redirect:"error" (no redirect to an internal target after the check);
76
+ // - an AbortController timeout;
77
+ // - the body is bounded to maxBytes while streaming.
78
+ // The residual DNS-rebinding window (resolve-then-connect) is accepted for an
79
+ // operator-configured, single-origin URL — same stance as the bearer satellite.
80
+ export async function guardedFetch(urlString, {
81
+ fetchImpl = globalThis.fetch,
82
+ lookupImpl = dnsLookup,
83
+ timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
84
+ maxBytes = DEFAULT_MAX_BYTES,
85
+ label = "url"
86
+ } = {}) {
87
+ const url = parseHttpsUrl(urlString, label);
88
+ if (isBlockedAddress(url.hostname)) {
89
+ throw new Error(`${label} host is a blocked (private/loopback/link-local/metadata) address`);
90
+ }
91
+ if (typeof fetchImpl !== "function") {
92
+ throw new Error("global fetch is unavailable; pass fetchImpl");
93
+ }
94
+ const records = await lookupImpl(url.hostname, { all: true });
95
+ for (const { address } of records) {
96
+ if (isBlockedAddress(address)) {
97
+ throw new Error(`${label} resolved to a blocked address`);
98
+ }
99
+ }
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
102
+ let res;
103
+ try {
104
+ res = await fetchImpl(url.href, { signal: controller.signal, redirect: "error" });
105
+ } finally {
106
+ clearTimeout(timer);
107
+ }
108
+ if (!res.ok) {
109
+ throw new Error(`${label} fetch failed: ${res.status}`);
110
+ }
111
+ const reader = res.body?.getReader?.();
112
+ if (!reader) {
113
+ const text = await res.text();
114
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
115
+ throw new Error(`${label} response exceeds the size limit`);
116
+ }
117
+ return text;
118
+ }
119
+ const chunks = [];
120
+ let total = 0;
121
+ for (;;) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ total += value.byteLength;
125
+ if (total > maxBytes) {
126
+ await reader.cancel();
127
+ throw new Error(`${label} response exceeds the size limit`);
128
+ }
129
+ chunks.push(Buffer.from(value));
130
+ }
131
+ return Buffer.concat(chunks).toString("utf8");
132
+ }
133
+
134
+ // A guarded key-material fetcher with a TTL cache + a refetch cooldown so an
135
+ // attacker's credential cannot pump the host's outbound requests (the kid-driven
136
+ // refetch is rate-limited, matching the bearer satellite). get() returns the
137
+ // cached body within ttlMs; otherwise it refetches, but no more often than
138
+ // cooldownMs (returning a stale cache during cooldown, or throwing if none).
139
+ export function createGuardedKeyFetcher({
140
+ url,
141
+ ttlMs = 300_000,
142
+ cooldownMs = 60_000,
143
+ now = () => Date.now(),
144
+ ...fetchOptions
145
+ } = {}) {
146
+ parseHttpsUrl(url, "keyMaterial.url"); // fail closed at construction on a bad URL
147
+ if (!Number.isFinite(ttlMs) || ttlMs < 0) {
148
+ throw new Error("keyMaterial.ttlMs must be a non-negative number");
149
+ }
150
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0) {
151
+ throw new Error("keyMaterial.cooldownMs must be a non-negative number");
152
+ }
153
+ let cache = null;
154
+ let fetchedAt = 0;
155
+ let lastAttemptAt = -Infinity;
156
+ let inflight = null;
157
+
158
+ return {
159
+ async get() {
160
+ const t = now();
161
+ if (cache !== null && (t - fetchedAt) < ttlMs) {
162
+ return cache;
163
+ }
164
+ if (inflight) {
165
+ return inflight;
166
+ }
167
+ // Cooldown: bound the outbound refetch rate. During cooldown serve the stale
168
+ // cache if we have one; otherwise fail closed.
169
+ if ((t - lastAttemptAt) < cooldownMs) {
170
+ if (cache !== null) {
171
+ return cache;
172
+ }
173
+ throw new Error("key material fetch is cooling down");
174
+ }
175
+ lastAttemptAt = t;
176
+ inflight = guardedFetch(url, { ...fetchOptions, label: "keyMaterial.url" })
177
+ .then((text) => {
178
+ cache = text;
179
+ fetchedAt = now();
180
+ inflight = null;
181
+ return text;
182
+ }, (error) => {
183
+ inflight = null;
184
+ throw error;
185
+ });
186
+ return inflight;
187
+ }
188
+ };
189
+ }