@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.
- package/package.json +7 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +90 -0
- 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 -21
- package/src/api/routes.ts +47 -1
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- 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/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +48 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- 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
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- 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
|
+
});
|