@cosmicdrift/kumiko-framework 0.201.0 → 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.
@@ -650,3 +650,61 @@ defineFeature("inventory", (r) => {
650
650
  expect(reparsed.patterns).toEqual([]);
651
651
  });
652
652
  });
653
+
654
+ // #2133 — matchArgString (shared by relation's second positional arg,
655
+ // hook's target, and useExtension's entity) only accepted a string literal
656
+ // or a name-resolving identifier. The parser itself is more permissive at
657
+ // two of those positions: useExtension's entity (round3.ts:445) and hook's
658
+ // target (hooks.ts:75) both additionally accept an inline `{ name: "..." }`
659
+ // object ref, same as the object-form fix in #2121 — so findCallForId
660
+ // couldn't locate a call authored that way. relation's second positional
661
+ // arg stays narrow on purpose: round2.ts:170 parses it via readNameLiteral,
662
+ // not readNameOrRef, so widening it would exceed what the parser accepts.
663
+ describe("callMatchesId — positional inline-ref args (#2133)", () => {
664
+ test("removePattern finds a positional useExtension whose entity is an inline { name } ref", () => {
665
+ const sf = makeSourceFile(`
666
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
667
+
668
+ defineFeature("inventory", (r) => {
669
+ r.useExtension("audit", { name: "item" });
670
+ });
671
+ `);
672
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" });
673
+ const reparsed = parseSourceFile(sf);
674
+ expect(reparsed.errors).toEqual([]);
675
+ expect(reparsed.patterns).toEqual([]);
676
+ });
677
+
678
+ test("removePattern finds a positional hook whose target is an inline { name } ref", () => {
679
+ const sf = makeSourceFile(`
680
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
681
+
682
+ defineFeature("hooks", (r) => {
683
+ r.hook("postSave", { name: "task" }, () => {});
684
+ });
685
+ `);
686
+ removePattern(sf, { kind: "hook", hookType: "postSave", target: "task" });
687
+ const reparsed = parseSourceFile(sf);
688
+ expect(reparsed.errors).toEqual([]);
689
+ expect(reparsed.patterns).toEqual([]);
690
+ });
691
+
692
+ // Boundary, not a gap left open by this fix: readDataLiteralNode (used by
693
+ // readNameOrRef's object-literal branch) keeps a nested Identifier as a
694
+ // RawRefSentinel instead of resolving it — same as the parser side, which
695
+ // is why this call fails to extract too (not just to patch-match).
696
+ test("does not resolve an identifier nested inside an inline-ref positional arg", () => {
697
+ const sf = makeSourceFile(`
698
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
699
+
700
+ const ENTITY_NAME = "item";
701
+
702
+ defineFeature("inventory", (r) => {
703
+ r.useExtension("audit", { name: ENTITY_NAME });
704
+ });
705
+ `);
706
+ expect(() =>
707
+ removePattern(sf, { kind: "useExtension", extensionName: "audit", entityName: "item" }),
708
+ ).toThrow(/no call found/);
709
+ });
710
+ });
@@ -374,7 +374,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
374
374
  );
375
375
  }
376
376
  if (matchFirstArgString(call, id.hookType)) {
377
- return matchArgString(call, 1, id.target);
377
+ return matchArgNameOrRef(call, 1, id.target);
378
378
  }
379
379
  return (
380
380
  matchObjectProperty(call, "type", id.hookType) &&
@@ -394,7 +394,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
394
394
  case "useExtension":
395
395
  // Positional: r.useExtension(name, entity) | Object: { name, entity }
396
396
  if (matchFirstArgString(call, id.extensionName)) {
397
- return matchArgString(call, 1, id.entityName);
397
+ return matchArgNameOrRef(call, 1, id.entityName);
398
398
  }
399
399
  return (
400
400
  matchObjectProperty(call, "name", id.extensionName) &&
@@ -433,6 +433,14 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
433
433
  // declaration (same-file or imported) to a string-literal initializer —
434
434
  // the dominant naming style in the framework's own bundled-features
435
435
  // (`r.entity(ENTITY, ...)`, `r.useExtension(EXT_X, ...)`, see #1746).
436
+ //
437
+ // Narrow on purpose: every kind's arg-0 (and relation's arg-1) is parsed
438
+ // via readNameLiteral, never readNameOrRef (see round2.ts/round3.ts) —
439
+ // widening this shared helper to readNameOrRef would let an object-form
440
+ // call's first argument (an ObjectLiteralExpression) match here too,
441
+ // short-circuiting the object-form branch in callMatchesId. Positions
442
+ // where the parser itself accepts an inline `{ name: "..." }` ref use
443
+ // `matchArgNameOrRef` below instead.
436
444
  function matchArgString(call: CallExpression, index: number, expected: string): boolean {
437
445
  const arg = call.getArguments()[index];
438
446
  if (!arg) return false;
@@ -443,6 +451,18 @@ function matchFirstArgString(call: CallExpression, expected: string): boolean {
443
451
  return matchArgString(call, 0, expected);
444
452
  }
445
453
 
454
+ // Like matchArgString, but via readNameOrRef — for the specific positional
455
+ // slots where the parser accepts an inline `{ name: "..." }` object ref in
456
+ // addition to a literal/identifier (useExtension's entity arg, hook's
457
+ // target arg; see round3.ts:445 / hooks.ts:75). Not a drop-in replacement
458
+ // for matchArgString: applying it to an arg-0 position would match an
459
+ // object-form call's first (and only) argument, see the comment above.
460
+ function matchArgNameOrRef(call: CallExpression, index: number, expected: string): boolean {
461
+ const arg = call.getArguments()[index];
462
+ if (!arg) return false;
463
+ return readNameOrRef(arg) === expected;
464
+ }
465
+
446
466
  // Object-form property values are resolved via readNameOrRef, not the
447
467
  // narrower readNameLiteral — some properties (useExtension's `entity`,
448
468
  // hook's `target`/`allOf`) accept an inline `{ name: "..." }` ref in the
@@ -201,7 +201,7 @@ describe("file validation", () => {
201
201
  });
202
202
 
203
203
  test("sniffMimeType recognizes gif, webp and pdf signatures", () => {
204
- expect(sniffMimeType(new TextEncoder().encode("GIF89a" + "x".repeat(20)))).toBe("image/gif");
204
+ expect(sniffMimeType(new TextEncoder().encode(`GIF89a${"x".repeat(20)}`))).toBe("image/gif");
205
205
  const webp = new Uint8Array([
206
206
  ...new TextEncoder().encode("RIFF"),
207
207
  0,
@@ -212,7 +212,7 @@ describe("file validation", () => {
212
212
  ...Array(20).fill(0),
213
213
  ]);
214
214
  expect(sniffMimeType(webp)).toBe("image/webp");
215
- expect(sniffMimeType(new TextEncoder().encode("%PDF-1.4" + "x".repeat(20)))).toBe(
215
+ expect(sniffMimeType(new TextEncoder().encode(`%PDF-1.4${"x".repeat(20)}`))).toBe(
216
216
  "application/pdf",
217
217
  );
218
218
  });
@@ -0,0 +1,37 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+ import { lookup } from "node:dns/promises";
3
+ import { egress } from "../egress";
4
+
5
+ // fw#2149 DoD requires proving TLS/SNI validation stays intact against a
6
+ // real HTTPS endpoint, not just a mock — the self-signed-cert tests in
7
+ // egress.test.ts pin the fetch-by-pinned-IP mechanism, this test pins it
8
+ // against a certificate chain issued by a real, publicly trusted CA.
9
+ // example.com is IANA-reserved and kept up for exactly this kind of use.
10
+ const REAL_HOST = "example.com";
11
+
12
+ let networkAvailable = true;
13
+
14
+ beforeAll(async () => {
15
+ try {
16
+ await lookup(REAL_HOST);
17
+ } catch {
18
+ networkAvailable = false;
19
+ }
20
+ });
21
+
22
+ describe("egress external: real HTTPS endpoint", () => {
23
+ test("connects through the pinned IP and validates the real certificate chain", async () => {
24
+ if (!networkAvailable) {
25
+ console.warn(
26
+ `egress real-endpoint test skipped: DNS resolution for ${REAL_HOST} failed (no network in this environment)`,
27
+ );
28
+ return;
29
+ }
30
+
31
+ const fetchIt = egress({ kind: "external" });
32
+ const res = await fetchIt(`https://${REAL_HOST}/`);
33
+
34
+ expect(res.status).toBe(200);
35
+ expect(await res.text()).toContain("Example Domain");
36
+ });
37
+ });
@@ -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
+ });