@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.
- package/package.json +7 -3
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
- package/src/api/__tests__/body-limit.test.ts +78 -4
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -22
- package/src/api/server.ts +1 -1
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/build-app-schema.test.ts +25 -0
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
- package/src/engine/feature-ast/patch.ts +22 -2
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- package/src/http/policy.ts +193 -0
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
});
|