@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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 (47) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  4. package/src/api/__tests__/api.test.ts +116 -1
  5. package/src/api/__tests__/batch.integration.test.ts +53 -0
  6. package/src/api/__tests__/body-limit.test.ts +90 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  8. package/src/api/api-constants.ts +44 -7
  9. package/src/api/auth-middleware.ts +19 -3
  10. package/src/api/index.ts +1 -0
  11. package/src/api/route-registrars.ts +19 -21
  12. package/src/api/routes.ts +47 -1
  13. package/src/api/server.ts +1 -1
  14. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  15. package/src/db/tenant-db.ts +46 -2
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/build-app-schema.test.ts +25 -0
  18. package/src/engine/boot-validator/detail-screens.ts +35 -0
  19. package/src/engine/boot-validator/index.ts +2 -0
  20. package/src/engine/entity-handlers.ts +8 -1
  21. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  23. package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  25. package/src/engine/feature-ast/extractors/events.ts +5 -3
  26. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  27. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  28. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  29. package/src/engine/feature-ast/patch.ts +48 -21
  30. package/src/engine/feature-ast/patterns.ts +18 -0
  31. package/src/engine/feature-ast/render.ts +19 -6
  32. package/src/engine/index.ts +1 -0
  33. package/src/files/__tests__/files.integration.test.ts +97 -1
  34. package/src/files/file-routes.ts +10 -2
  35. package/src/files/types.ts +72 -0
  36. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  37. package/src/http/__tests__/egress.test.ts +440 -0
  38. package/src/http/__tests__/policy.test.ts +125 -0
  39. package/src/http/egress.ts +158 -0
  40. package/src/http/index.ts +2 -0
  41. package/src/http/policy.ts +193 -0
  42. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  43. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  44. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  45. package/src/pipeline/dispatch-batch.ts +11 -5
  46. package/src/pipeline/dispatch-shared.ts +42 -16
  47. package/src/pipeline/idempotency.ts +91 -30
@@ -0,0 +1,440 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { buildPinnedRequest, egress, withManualRedirect, withOriginalUrl } from "../egress";
7
+
8
+ let server: ReturnType<typeof Bun.serve>;
9
+ let port: number;
10
+ const requestCounts = new Map<string, number>();
11
+
12
+ function countedResponse(path: string, respond: () => Response): Response {
13
+ requestCounts.set(path, (requestCounts.get(path) ?? 0) + 1);
14
+ return respond();
15
+ }
16
+
17
+ beforeAll(() => {
18
+ server = Bun.serve({
19
+ hostname: "127.0.0.1",
20
+ port: 0,
21
+ fetch(req) {
22
+ const url = new URL(req.url);
23
+ if (url.pathname === "/ok") {
24
+ return countedResponse("/ok", () => new Response("hello", { status: 200 }));
25
+ }
26
+ if (url.pathname === "/final") {
27
+ return countedResponse("/final", () => new Response("final", { status: 200 }));
28
+ }
29
+ if (url.pathname === "/allowed-redirect") {
30
+ return countedResponse(
31
+ "/allowed-redirect",
32
+ () =>
33
+ new Response(null, {
34
+ status: 302,
35
+ headers: { location: `http://127.0.0.1:${port}/final` },
36
+ }),
37
+ );
38
+ }
39
+ if (url.pathname === "/blocked-redirect") {
40
+ // Same physical server, different hostname string ("localhost" is
41
+ // not in the allowlist used below even though it resolves here too).
42
+ return countedResponse(
43
+ "/blocked-redirect",
44
+ () =>
45
+ new Response(null, {
46
+ status: 302,
47
+ headers: { location: `http://localhost:${port}/final` },
48
+ }),
49
+ );
50
+ }
51
+ if (url.pathname === "/cross-host-redirect") {
52
+ // Redirects to a *different* allowlisted host (not just a
53
+ // disallowed one) — this must still be rejected even though both
54
+ // hosts individually pass assertAllowedHost, because init.headers
55
+ // are replayed on every hop and must not leak across hosts.
56
+ return countedResponse(
57
+ "/cross-host-redirect",
58
+ () =>
59
+ new Response(null, {
60
+ status: 302,
61
+ headers: { location: `http://localhost:${port}/final` },
62
+ }),
63
+ );
64
+ }
65
+ if (url.pathname === "/redirect-loop") {
66
+ return countedResponse(
67
+ "/redirect-loop",
68
+ () =>
69
+ new Response(null, {
70
+ status: 302,
71
+ headers: { location: `http://127.0.0.1:${port}/redirect-loop` },
72
+ }),
73
+ );
74
+ }
75
+ return countedResponse(url.pathname, () => new Response("not found", { status: 404 }));
76
+ },
77
+ });
78
+ if (typeof server.port !== "number") {
79
+ throw new Error("test server did not report a bound port");
80
+ }
81
+ port = server.port;
82
+ });
83
+
84
+ afterAll(() => {
85
+ server.stop(true);
86
+ });
87
+
88
+ describe("egress internal", () => {
89
+ test("fetches an allowlisted host directly", async () => {
90
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1"] });
91
+ const res = await fetchIt(`http://127.0.0.1:${port}/ok`);
92
+ expect(res.status).toBe(200);
93
+ expect(await res.text()).toBe("hello");
94
+ });
95
+
96
+ test("rejects a host not in the allowlist without making any request", async () => {
97
+ const before = requestCounts.get("/ok") ?? 0;
98
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1"] });
99
+ await expect(fetchIt(`http://localhost:${port}/ok`)).rejects.toThrow();
100
+ expect(requestCounts.get("/ok") ?? 0).toBe(before); // never dialed
101
+ });
102
+
103
+ test("follows a redirect to a host that stays inside the allowlist", async () => {
104
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1"] });
105
+ const res = await fetchIt(`http://127.0.0.1:${port}/allowed-redirect`);
106
+ expect(res.status).toBe(200);
107
+ expect(await res.text()).toBe("final");
108
+ expect(requestCounts.get("/allowed-redirect")).toBeGreaterThan(0);
109
+ expect(requestCounts.get("/final")).toBeGreaterThan(0);
110
+ });
111
+
112
+ test("rejects a redirect whose target host falls outside the allowlist, without following it", async () => {
113
+ const before = requestCounts.get("/final") ?? 0;
114
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1"] });
115
+ await expect(fetchIt(`http://127.0.0.1:${port}/blocked-redirect`)).rejects.toThrow();
116
+ expect(requestCounts.get("/final") ?? 0).toBe(before); // redirect target never dialed
117
+ });
118
+
119
+ test("does not silently follow a disallowed redirect even if the caller passes redirect: 'follow'", async () => {
120
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1"] });
121
+ await expect(
122
+ fetchIt(`http://127.0.0.1:${port}/blocked-redirect`, { redirect: "follow" }),
123
+ ).rejects.toThrow();
124
+ });
125
+
126
+ test("rejects a redirect to a different host even when both hosts are allowlisted", async () => {
127
+ const before = requestCounts.get("/final") ?? 0;
128
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1", "localhost"] });
129
+ await expect(fetchIt(`http://127.0.0.1:${port}/cross-host-redirect`)).rejects.toThrow(
130
+ /crosses host/,
131
+ );
132
+ expect(requestCounts.get("/final") ?? 0).toBe(before); // never dialed the second host
133
+ });
134
+
135
+ test("throws once the redirect hop cap is exceeded", async () => {
136
+ const fetchIt = egress({ kind: "internal", allowHosts: ["127.0.0.1"] });
137
+ await expect(fetchIt(`http://127.0.0.1:${port}/redirect-loop`)).rejects.toThrow(/redirects/);
138
+ });
139
+ });
140
+
141
+ describe("egress external / tenant-supplied", () => {
142
+ test.each(["external", "tenant-supplied"] as const)(
143
+ "%s denies a loopback target without connecting to it",
144
+ async (kind) => {
145
+ const before = requestCounts.get("/ok") ?? 0;
146
+ const fetchIt = egress(
147
+ kind === "external" ? { kind: "external" } : { kind: "tenant-supplied" },
148
+ );
149
+ await expect(fetchIt(`http://127.0.0.1:${port}/ok`)).rejects.toThrow();
150
+ expect(requestCounts.get("/ok") ?? 0).toBe(before); // guard fired before any connection
151
+ },
152
+ );
153
+
154
+ test.each(["external", "tenant-supplied"] as const)(
155
+ "%s rejects a non-http(s) scheme",
156
+ async (kind) => {
157
+ const fetchIt = egress(
158
+ kind === "external" ? { kind: "external" } : { kind: "tenant-supplied" },
159
+ );
160
+ await expect(fetchIt("file:///etc/passwd")).rejects.toThrow();
161
+ },
162
+ );
163
+
164
+ // `external`/`tenant-supplied` never auto-follow a redirect (see
165
+ // withManualRedirect above) — the caller sees the 3xx itself and is
166
+ // expected to call egress() again with the Location header to follow it.
167
+ // That means every hop is a fresh egress() call, and this pins that the
168
+ // DNS-rebinding-safe host check applies to a redirect target exactly the
169
+ // way it applies to any other URL — not a special case that could be
170
+ // missed.
171
+ test.each(["external", "tenant-supplied"] as const)(
172
+ "%s: following a redirect Location by calling egress() again re-validates the new host",
173
+ async (kind) => {
174
+ const fetchIt = egress(
175
+ kind === "external" ? { kind: "external" } : { kind: "tenant-supplied" },
176
+ );
177
+ const redirectLocation = "http://169.254.169.254/latest/meta-data/"; // simulated hop target
178
+ await expect(fetchIt(redirectLocation)).rejects.toThrow();
179
+ },
180
+ );
181
+ });
182
+
183
+ describe("withOriginalUrl", () => {
184
+ test("overwrites Response.url to the original request URL, not the pinned IP", async () => {
185
+ const echoServer = Bun.serve({
186
+ hostname: "127.0.0.1",
187
+ port: 0,
188
+ fetch: () => new Response("ok"),
189
+ });
190
+ try {
191
+ const originalUrl = new URL("http://vhost.example/some/path");
192
+ const pinned = buildPinnedRequest(
193
+ originalUrl,
194
+ { address: "127.0.0.1", family: 4 },
195
+ undefined,
196
+ );
197
+ pinned.url.port = String(echoServer.port);
198
+
199
+ const res = await fetch(pinned.url, pinned.init);
200
+ expect(res.url).not.toBe(originalUrl.toString()); // sanity: fetch itself reports the pinned IP
201
+
202
+ expect(withOriginalUrl(res, originalUrl).url).toBe(originalUrl.toString());
203
+ } finally {
204
+ echoServer.stop(true);
205
+ }
206
+ });
207
+
208
+ // This is the exact caller pattern runEgress's own comment promises:
209
+ // "call egress() again with the Location header". A relative Location
210
+ // resolved against the pinned-IP res.url would land on the IP, and the
211
+ // resulting egress() call would then pin/SNI-validate against that IP
212
+ // instead of a real hostname — this pins that it resolves against the
213
+ // original host instead.
214
+ test("a relative redirect Location resolves against the original host, not the pinned IP", async () => {
215
+ const redirectServer = Bun.serve({
216
+ hostname: "127.0.0.1",
217
+ port: 0,
218
+ fetch: () => new Response(null, { status: 302, headers: { location: "/next" } }),
219
+ });
220
+ try {
221
+ const originalUrl = new URL("http://vhost.example/start");
222
+ const pinned = buildPinnedRequest(
223
+ originalUrl,
224
+ { address: "127.0.0.1", family: 4 },
225
+ undefined,
226
+ );
227
+ pinned.url.port = String(redirectServer.port);
228
+
229
+ const res = withOriginalUrl(
230
+ await fetch(pinned.url, withManualRedirect(pinned.init)),
231
+ originalUrl,
232
+ );
233
+ const location = res.headers.get("location");
234
+ if (!location) throw new Error("test server did not send a Location header");
235
+ const next = new URL(location, res.url);
236
+
237
+ expect(next.hostname).toBe("vhost.example"); // not the pinned 127.0.0.1
238
+ expect(next.pathname).toBe("/next");
239
+ } finally {
240
+ redirectServer.stop(true);
241
+ }
242
+ });
243
+ });
244
+
245
+ describe("buildPinnedRequest", () => {
246
+ test("sends fetch an IP-literal URL, never the original hostname", () => {
247
+ const url = new URL("https://attacker-controlled.example/path?x=1");
248
+ const pinned = buildPinnedRequest(url, { address: "203.0.113.5", family: 4 }, undefined);
249
+
250
+ expect(pinned.url.hostname).toBe("203.0.113.5");
251
+ expect(pinned.url.pathname).toBe("/path");
252
+ expect(pinned.url.search).toBe("?x=1");
253
+ // Nothing hostname-shaped survives into the request fetch() receives —
254
+ // there is no hostname left for fetch to resolve a second time.
255
+ expect(pinned.url.href).not.toContain("attacker-controlled.example");
256
+ });
257
+
258
+ test("preserves the original hostname in the Host header", () => {
259
+ const url = new URL("https://attacker-controlled.example/");
260
+ const pinned = buildPinnedRequest(url, { address: "203.0.113.5", family: 4 }, undefined);
261
+
262
+ expect(new Headers(pinned.init.headers).get("host")).toBe("attacker-controlled.example");
263
+ });
264
+
265
+ test("preserves the original hostname in tls.servername for https", () => {
266
+ const url = new URL("https://attacker-controlled.example/");
267
+ const pinned = buildPinnedRequest(url, { address: "203.0.113.5", family: 4 }, undefined);
268
+
269
+ expect(pinned.init.tls).toEqual({ servername: "attacker-controlled.example" });
270
+ });
271
+
272
+ test("omits tls for http (nothing to pin SNI for)", () => {
273
+ const url = new URL("http://attacker-controlled.example/");
274
+ const pinned = buildPinnedRequest(url, { address: "203.0.113.5", family: 4 }, undefined);
275
+
276
+ expect(pinned.init.tls).toBeUndefined();
277
+ });
278
+
279
+ test("pins an IPv6 address using bracket notation", () => {
280
+ const url = new URL("https://example.com/");
281
+ const pinned = buildPinnedRequest(
282
+ url,
283
+ { address: "2606:4700:10::6814:179a", family: 6 },
284
+ undefined,
285
+ );
286
+
287
+ expect(pinned.url.hostname).toBe("[2606:4700:10::6814:179a]");
288
+ });
289
+
290
+ test("overrides a caller-supplied Host header rather than merging it", () => {
291
+ const url = new URL("https://real-host.example/");
292
+ const pinned = buildPinnedRequest(
293
+ url,
294
+ { address: "203.0.113.5", family: 4 },
295
+ { headers: { Host: "spoofed.example" } },
296
+ );
297
+
298
+ expect(new Headers(pinned.init.headers).get("host")).toBe("real-host.example");
299
+ });
300
+
301
+ test("preserves a non-default port in both the pinned URL and the Host header", () => {
302
+ const url = new URL("https://attacker-controlled.example:9443/");
303
+ const pinned = buildPinnedRequest(url, { address: "203.0.113.5", family: 4 }, undefined);
304
+
305
+ expect(pinned.url.port).toBe("9443");
306
+ expect(new Headers(pinned.init.headers).get("host")).toBe("attacker-controlled.example:9443");
307
+ });
308
+ });
309
+
310
+ describe("egress external / tenant-supplied: connects to the pinned address for real", () => {
311
+ test("the Host header set by buildPinnedRequest reaches the origin unmodified", async () => {
312
+ const echo = Bun.serve({
313
+ hostname: "127.0.0.1",
314
+ port: 0,
315
+ fetch: (req) => new Response(req.headers.get("host") ?? "MISSING"),
316
+ });
317
+ try {
318
+ const url = new URL(`http://vhost.example/`);
319
+ const pinned = buildPinnedRequest(url, { address: "127.0.0.1", family: 4 }, undefined);
320
+ pinned.url.port = String(echo.port);
321
+ const res = await fetch(pinned.url, pinned.init);
322
+ expect(await res.text()).toBe("vhost.example");
323
+ } finally {
324
+ echo.stop(true);
325
+ }
326
+ });
327
+ });
328
+
329
+ describe("egress external / tenant-supplied: TLS/SNI stay intact when connecting by pinned IP", () => {
330
+ const CERT_HOSTNAME = "kumiko-egress-test.local";
331
+
332
+ let tlsServer: ReturnType<typeof Bun.serve>;
333
+ let cert: Buffer;
334
+ let certDir: string;
335
+
336
+ beforeAll(() => {
337
+ // Generated fresh per test run rather than checked in as a fixture —
338
+ // a committed .pem/.key pair (even a self-signed, test-only one) is
339
+ // what secret scanners and this repo's push protection are watching
340
+ // for, and there is no precedent for one in this repo's history.
341
+ certDir = mkdtempSync(join(tmpdir(), "kumiko-egress-tls-test-"));
342
+ const certPath = join(certDir, "cert.pem");
343
+ const keyPath = join(certDir, "key.pem");
344
+ execFileSync("openssl", [
345
+ "req",
346
+ "-x509",
347
+ "-newkey",
348
+ "rsa:2048",
349
+ "-nodes",
350
+ "-days",
351
+ "1",
352
+ "-keyout",
353
+ keyPath,
354
+ "-out",
355
+ certPath,
356
+ "-subj",
357
+ `/CN=${CERT_HOSTNAME}`,
358
+ "-addext",
359
+ `subjectAltName=DNS:${CERT_HOSTNAME}`,
360
+ ]);
361
+ cert = readFileSync(certPath);
362
+ const key = readFileSync(keyPath);
363
+
364
+ tlsServer = Bun.serve({
365
+ hostname: "127.0.0.1",
366
+ port: 0,
367
+ tls: { cert, key },
368
+ fetch: () => new Response("ok"),
369
+ });
370
+ });
371
+
372
+ afterAll(() => {
373
+ tlsServer.stop(true);
374
+ rmSync(certDir, { recursive: true, force: true });
375
+ });
376
+
377
+ // buildPinnedRequest always sets tls.servername itself (it must not be
378
+ // overridable by the caller, same as the redirect mode below) — so `ca`
379
+ // is added to its result afterwards, purely to make this self-signed
380
+ // fixture trusted for the test. Production call sites never set `ca`;
381
+ // they rely on the system trust store, which the real-endpoint
382
+ // integration test below exercises.
383
+ function trustFixtureCa(init: RequestInit & { tls?: { servername: string } }): RequestInit {
384
+ return { ...init, tls: { ...init.tls, ca: cert } } as RequestInit;
385
+ }
386
+
387
+ test("validates the certificate against the pinned servername and succeeds when it matches", async () => {
388
+ const url = new URL(`https://${CERT_HOSTNAME}/`);
389
+ const pinned = buildPinnedRequest(url, { address: "127.0.0.1", family: 4 }, undefined);
390
+ pinned.url.port = String(tlsServer.port);
391
+
392
+ const res = await fetch(pinned.url, trustFixtureCa(pinned.init));
393
+ expect(res.status).toBe(200);
394
+ });
395
+
396
+ test("fails closed when the pinned servername does not match the certificate", async () => {
397
+ const url = new URL("https://attacker-does-not-own-this-cert.example/");
398
+ const pinned = buildPinnedRequest(url, { address: "127.0.0.1", family: 4 }, undefined);
399
+ pinned.url.port = String(tlsServer.port);
400
+
401
+ // A bare `.rejects.toThrow()` would also pass if the server were simply
402
+ // unreachable, proving nothing about the SNI/cert check itself — match
403
+ // the actual TLS hostname-mismatch error so the test fails if the
404
+ // rejection reason ever silently changes to something unrelated.
405
+ await expect(fetch(pinned.url, trustFixtureCa(pinned.init))).rejects.toThrow(
406
+ /ALTNAME|certificate/i,
407
+ );
408
+ });
409
+ });
410
+
411
+ describe("withManualRedirect", () => {
412
+ test("always forces redirect: 'manual', even if the caller's init requests 'follow'", () => {
413
+ expect(withManualRedirect({ redirect: "follow", headers: { "x-test": "1" } })).toEqual({
414
+ redirect: "manual",
415
+ headers: { "x-test": "1" },
416
+ });
417
+ });
418
+
419
+ test("sets redirect: 'manual' when no init is given", () => {
420
+ expect(withManualRedirect(undefined)).toEqual({ redirect: "manual" });
421
+ });
422
+
423
+ // The actual runEgress call path is
424
+ // withManualRedirect(buildPinnedRequest(...).init) — withManualRedirect's
425
+ // own type signature is plain RequestInit, which type-erases `tls`.
426
+ // Pinning this hermetically (not just via the network-guarded TLS
427
+ // integration tests above) protects against a future refactor that
428
+ // builds the returned object explicitly, field by field, instead of
429
+ // spreading `init` — which would silently drop `tls` and disable SNI
430
+ // pinning without any test failing.
431
+ test("preserves tls.servername from buildPinnedRequest through the spread", () => {
432
+ const url = new URL("https://attacker-controlled.example/");
433
+ const pinned = buildPinnedRequest(url, { address: "203.0.113.5", family: 4 }, undefined);
434
+
435
+ expect(withManualRedirect(pinned.init)).toMatchObject({
436
+ redirect: "manual",
437
+ tls: { servername: "attacker-controlled.example" },
438
+ });
439
+ });
440
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { lookup } from "node:dns/promises";
3
+ import { assertAllowedHost, assertHttpScheme, isBlockedIp, resolvePublicHost } from "../policy";
4
+
5
+ describe("isBlockedIp", () => {
6
+ test.each([
7
+ ["169.254.169.254", true], // cloud metadata — the core SSRF target
8
+ ["10.0.0.1", true],
9
+ ["172.16.5.4", true],
10
+ ["172.31.255.255", true],
11
+ ["192.168.1.1", true],
12
+ ["127.0.0.1", true],
13
+ ["0.0.0.0", true],
14
+ ["100.64.0.1", true], // CGNAT
15
+ ["224.0.0.1", true], // multicast
16
+ ["::1", true],
17
+ ["fc00::1", true], // unique-local
18
+ ["fe80::1", true], // link-local
19
+ ["::ffff:10.0.0.1", true], // IPv4-mapped private (dotted form)
20
+ ["::ffff:a9fe:a9fe", true], // IPv4-mapped cloud metadata (hex form, as new URL() normalizes it)
21
+ ["0:0:0:0:0:ffff:169.254.169.254", true], // same address, uncompressed + dotted
22
+ ["0:0:0:0:0:ffff:a9fe:a9fe", true], // same address, uncompressed + hex
23
+ ["fec0::1", true], // deprecated site-local, RFC 3879
24
+ ["FEC0:0:0:0:0:0:0:1", true], // same range, uncompressed + uppercase
25
+ ["feff::1", true], // upper end of fec0::/10
26
+ ["64:ff9b::169.254.169.254", true], // NAT64-embedded cloud metadata (RFC 6052)
27
+ ["2002:a9fe:a9fe::", true], // 6to4-embedded cloud metadata (RFC 3056)
28
+ ["not-an-ip", true], // fail closed
29
+ ["8.8.8.8", false],
30
+ ["1.1.1.1", false],
31
+ ["172.15.0.1", false], // just outside 172.16/12
32
+ ["172.32.0.1", false],
33
+ ["93.184.216.34", false],
34
+ ["2606:2800:220:1:248:1893:25c8:1946", false],
35
+ ["64:ff9b::8.8.8.8", false], // NAT64-embedded public IP
36
+ ["2002:808:808::", false], // 6to4-embedded public IP (8.8.8.8)
37
+ ])("%s -> blocked=%p", (ip, blocked) => {
38
+ expect(isBlockedIp(ip)).toBe(blocked);
39
+ });
40
+ });
41
+
42
+ describe("assertHttpScheme", () => {
43
+ test("accepts http and https", () => {
44
+ expect(() => assertHttpScheme(new URL("http://example.com"))).not.toThrow();
45
+ expect(() => assertHttpScheme(new URL("https://example.com"))).not.toThrow();
46
+ });
47
+
48
+ test("rejects other schemes", () => {
49
+ expect(() => assertHttpScheme(new URL("file:///etc/passwd"))).toThrow();
50
+ expect(() => assertHttpScheme(new URL("ftp://example.com"))).toThrow();
51
+ });
52
+ });
53
+
54
+ describe("resolvePublicHost", () => {
55
+ test("rejects a literal private-IP host without any DNS lookup", async () => {
56
+ await expect(
57
+ resolvePublicHost(new URL("http://169.254.169.254/latest/meta-data/")),
58
+ ).rejects.toThrow();
59
+ });
60
+
61
+ test("rejects the hex IPv4-mapped form new URL() normalizes ::ffff:169.254.169.254 into", async () => {
62
+ await expect(resolvePublicHost(new URL("http://[::ffff:169.254.169.254]/"))).rejects.toThrow();
63
+ });
64
+
65
+ test("allows a public IP-literal host and pins that exact address", async () => {
66
+ await expect(resolvePublicHost(new URL("http://93.184.216.34/"))).resolves.toEqual({
67
+ address: "93.184.216.34",
68
+ family: 4,
69
+ });
70
+ });
71
+
72
+ test("rejects a URL with embedded credentials", async () => {
73
+ await expect(resolvePublicHost(new URL("http://user:pass@93.184.216.34/"))).rejects.toThrow(
74
+ /credentials/,
75
+ );
76
+ });
77
+
78
+ // DNS-rebinding simulation: a naive "resolve, check, then let fetch()
79
+ // resolve again to connect" implementation would call the resolver twice
80
+ // and could get a different (private) answer the second time. This pins
81
+ // the actual mechanism that closes that window: resolvePublicHost only
82
+ // ever resolves once and returns the single address from that resolution
83
+ // for the caller to connect to — there is no second call left for a
84
+ // rebinding DNS server to answer differently.
85
+ test("resolves the host exactly once and pins the address from that resolution", async () => {
86
+ const calls: string[] = [];
87
+ // Test double only needs the `(host, { all: true }) => LookupAddress[]`
88
+ // shape resolvePublicHost actually calls, not `lookup`'s full overload
89
+ // set — double-cast through `unknown` at this test-only boundary.
90
+ const fakeLookup = (async (hostname: string) => {
91
+ calls.push(hostname);
92
+ return [{ address: "203.0.113.5", family: 4 }];
93
+ }) as unknown as typeof lookup;
94
+
95
+ const resolved = await resolvePublicHost(new URL("http://rebinding.example/"), fakeLookup);
96
+
97
+ expect(resolved).toEqual({ address: "203.0.113.5", family: 4 });
98
+ expect(calls).toEqual(["rebinding.example"]); // exactly one resolution
99
+ });
100
+
101
+ test("rejects when any address in the resolution is private, even if another is public", async () => {
102
+ const fakeLookup = (async () => [
103
+ { address: "203.0.113.5", family: 4 },
104
+ { address: "10.0.0.1", family: 4 },
105
+ ]) as unknown as typeof lookup;
106
+
107
+ await expect(
108
+ resolvePublicHost(new URL("http://rebinding.example/"), fakeLookup),
109
+ ).rejects.toThrow(/non-public/);
110
+ });
111
+ });
112
+
113
+ describe("assertAllowedHost", () => {
114
+ test("allows a host present in allowHosts (case-insensitive)", () => {
115
+ expect(() =>
116
+ assertAllowedHost(new URL("http://Internal-Service.local/"), ["internal-service.local"]),
117
+ ).not.toThrow();
118
+ });
119
+
120
+ test("rejects a host absent from allowHosts", () => {
121
+ expect(() =>
122
+ assertAllowedHost(new URL("http://other.local/"), ["internal-service.local"]),
123
+ ).toThrow();
124
+ });
125
+ });