@cosmicdrift/kumiko-framework 0.283.0 → 0.285.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 +4 -3
- package/src/changes.json +6 -0
- package/src/engine/__tests__/build-app-schema-url-prefill.test.ts +27 -2
- package/src/engine/boot-validator/url-prefill-fields.ts +29 -13
- package/src/http/index.ts +2 -2
- package/src/http/__tests__/egress-real-endpoint.integration.test.ts +0 -37
- package/src/http/__tests__/egress.test.ts +0 -440
- package/src/http/__tests__/policy.test.ts +0 -125
- package/src/http/egress.ts +0 -158
- package/src/http/policy.ts +0 -193
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.285.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.285.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.285.0",
|
|
202
203
|
"bullmq": "^5.76.7",
|
|
203
204
|
"bun-types": "^1.3.13",
|
|
204
205
|
"hono": "^4.13.1",
|
|
@@ -214,7 +215,7 @@
|
|
|
214
215
|
"zod": "^4.4.3"
|
|
215
216
|
},
|
|
216
217
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.285.0",
|
|
218
219
|
"bun-types": "^1.3.13",
|
|
219
220
|
"pino-pretty": "^13.1.3"
|
|
220
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.285.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Add a `tooling` repo kind, scoped out of guards by default",
|
|
6
|
+
"detail": "`kumiko-repo-manifest`'s `repoKindSchema` gains a `\"tooling\"` value for a repo with no product code (infra, Pulumi, build tooling). `kumiko-guards`' `scanRoots` treats an omitted `ScanSpec.kinds` as \"every root except tooling\" instead of \"every root\" — only 7 of ~60 guards declare `kinds` today, so without this a tooling repo would have been silently pulled into every product-oriented guard; a guard now has to name `\"tooling\"` in `kinds` explicitly to scan one. Separately, `kumiko-framework`'s boot-validator now includes the implicit parent-id field in a form screen's `urlPrefillFields` when it's reached via a relatedList toolbarAction that declares no `params` of its own — the renderer already sends that field (`parentFilter.field`, else `parentParam`, else `\"id\"`), the boot-validator just wasn't allowlisting it."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.283.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -93,6 +93,21 @@ const leasesFeature = defineFeature("leases", (r) => {
|
|
|
93
93
|
},
|
|
94
94
|
],
|
|
95
95
|
},
|
|
96
|
+
{
|
|
97
|
+
kind: "relatedList",
|
|
98
|
+
title: "Custom-key items",
|
|
99
|
+
query: "leases:query:lease:items",
|
|
100
|
+
columns: ["name"],
|
|
101
|
+
parentParam: "customParentId",
|
|
102
|
+
toolbarActions: [
|
|
103
|
+
{
|
|
104
|
+
kind: "navigate",
|
|
105
|
+
id: "silent-add-custom",
|
|
106
|
+
label: "Silent add",
|
|
107
|
+
screen: "item-toolbar-custom-parent",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
96
111
|
],
|
|
97
112
|
},
|
|
98
113
|
metrics: [
|
|
@@ -121,6 +136,12 @@ const leasesFeature = defineFeature("leases", (r) => {
|
|
|
121
136
|
entity: "item",
|
|
122
137
|
layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
|
|
123
138
|
});
|
|
139
|
+
r.screen({
|
|
140
|
+
id: "item-toolbar-custom-parent",
|
|
141
|
+
type: "entityEdit",
|
|
142
|
+
entity: "item",
|
|
143
|
+
layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
|
|
144
|
+
});
|
|
124
145
|
r.screen({
|
|
125
146
|
id: "record-payment",
|
|
126
147
|
type: "actionForm",
|
|
@@ -191,7 +212,11 @@ describe("buildAppSchema — urlPrefillFields derived from navigate params", ()
|
|
|
191
212
|
expect(urlPrefillFieldsOf("leases", "item-toolbar-note")).toEqual(["leaseId"]);
|
|
192
213
|
});
|
|
193
214
|
|
|
194
|
-
test("a relatedList toolbarAction without params contributes
|
|
195
|
-
expect(urlPrefillFieldsOf("leases", "item-toolbar-silent")).toEqual([]);
|
|
215
|
+
test("a relatedList toolbarAction without params still contributes the implicit parent-id field", () => {
|
|
216
|
+
expect(urlPrefillFieldsOf("leases", "item-toolbar-silent")).toEqual(["id"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a relatedList toolbarAction without params uses the section's own parentParam key", () => {
|
|
220
|
+
expect(urlPrefillFieldsOf("leases", "item-toolbar-custom-parent")).toEqual(["customParentId"]);
|
|
196
221
|
});
|
|
197
222
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { qualifyEntityName } from "../qualified-name";
|
|
2
2
|
import type { FeatureDefinition } from "../types";
|
|
3
3
|
import type {
|
|
4
|
+
EditRelatedListSection,
|
|
4
5
|
ProjectionDetailScreenDefinition,
|
|
5
6
|
RowFieldExtractor,
|
|
6
7
|
ScreenDefinition,
|
|
@@ -26,23 +27,29 @@ type NavigateActionLike = {
|
|
|
26
27
|
readonly entityId?: string;
|
|
27
28
|
readonly params?: RowFieldExtractor;
|
|
28
29
|
};
|
|
29
|
-
type NavigateActionWithParams = NavigateActionLike & { readonly params: RowFieldExtractor };
|
|
30
30
|
type ScopedNavigateAction = {
|
|
31
31
|
readonly action: NavigateActionLike;
|
|
32
32
|
readonly sourceScreenEntity: string | undefined;
|
|
33
|
+
/** Used when the action itself declares no `params` — the field a
|
|
34
|
+
* relatedList toolbarAction's parent id lands under at runtime
|
|
35
|
+
* (RelatedListSection's `navigatePrefill`, related-list-section.tsx). */
|
|
36
|
+
readonly implicitParams?: RowFieldExtractor;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
function isNavigateKind(action: NavigateActionLike): boolean {
|
|
36
40
|
return action.kind === undefined || action.kind === "navigate";
|
|
37
41
|
}
|
|
38
|
-
function isNavigateWithParams(action: NavigateActionLike): action is NavigateActionWithParams {
|
|
39
|
-
return isNavigateKind(action) && action.params !== undefined;
|
|
40
|
-
}
|
|
41
42
|
function scopeActions(
|
|
42
43
|
actions: readonly NavigateActionLike[] | undefined,
|
|
43
44
|
sourceScreenEntity: string | undefined,
|
|
45
|
+
implicitParams?: RowFieldExtractor,
|
|
44
46
|
): ScopedNavigateAction[] {
|
|
45
|
-
return (actions ?? []).map((action) => ({ action, sourceScreenEntity }));
|
|
47
|
+
return (actions ?? []).map((action) => ({ action, sourceScreenEntity, implicitParams }));
|
|
48
|
+
}
|
|
49
|
+
// Same key the renderer falls back to when a relatedList toolbarAction has no
|
|
50
|
+
// `params` of its own: `parentFilter.field`, else `parentParam`, else "id".
|
|
51
|
+
function relatedListParentParamField(section: EditRelatedListSection): string {
|
|
52
|
+
return section.parentFilter?.field ?? section.parentParam ?? "id";
|
|
46
53
|
}
|
|
47
54
|
function projectionDetailNavigateActions(
|
|
48
55
|
screen: ProjectionDetailScreenDefinition,
|
|
@@ -53,7 +60,11 @@ function projectionDetailNavigateActions(
|
|
|
53
60
|
section.kind === "relatedList" ? scopeActions(section.rowActions, undefined) : [],
|
|
54
61
|
),
|
|
55
62
|
...screen.layout.sections.flatMap((section) =>
|
|
56
|
-
section.kind === "relatedList"
|
|
63
|
+
section.kind === "relatedList"
|
|
64
|
+
? scopeActions(section.toolbarActions, undefined, {
|
|
65
|
+
pick: [relatedListParentParamField(section)],
|
|
66
|
+
})
|
|
67
|
+
: [],
|
|
57
68
|
),
|
|
58
69
|
...(screen.metrics ?? []).flatMap((metric) =>
|
|
59
70
|
typeof metric === "string" || metric.navigate === undefined
|
|
@@ -77,25 +88,30 @@ function navigateActionsOf(screen: ScreenDefinition): ScopedNavigateAction[] {
|
|
|
77
88
|
}
|
|
78
89
|
}
|
|
79
90
|
function toNavigateParamsSource(
|
|
80
|
-
action:
|
|
91
|
+
action: NavigateActionLike,
|
|
92
|
+
params: RowFieldExtractor,
|
|
81
93
|
sourceScreenEntity: string | undefined,
|
|
82
94
|
): NavigateParamsSource {
|
|
83
95
|
return {
|
|
84
96
|
...(action.screen !== undefined && { screen: action.screen }),
|
|
85
97
|
...(action.entity !== undefined && { entity: action.entity }),
|
|
86
98
|
...(action.entityId !== undefined && { entityId: action.entityId }),
|
|
87
|
-
params
|
|
99
|
+
params,
|
|
88
100
|
sourceScreenEntity,
|
|
89
101
|
};
|
|
90
102
|
}
|
|
91
103
|
|
|
92
|
-
// Every declarative navigate that writes
|
|
104
|
+
// Every declarative navigate that writes params into the target's URL —
|
|
93
105
|
// the same sources validateScreens pairs with validateRowActionNavigateParams,
|
|
94
|
-
// plus projectionDetail metrics (runMetricNavigate), which reuse that shape
|
|
106
|
+
// plus projectionDetail metrics (runMetricNavigate), which reuse that shape —
|
|
107
|
+
// plus a relatedList toolbarAction with no `params` of its own, which still
|
|
108
|
+
// writes its implicit parent-id param at runtime (see `implicitParams`).
|
|
95
109
|
function navigateParamsSources(screen: ScreenDefinition): NavigateParamsSource[] {
|
|
96
|
-
return navigateActionsOf(screen).flatMap(({ action, sourceScreenEntity }) =>
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
return navigateActionsOf(screen).flatMap(({ action, sourceScreenEntity, implicitParams }) => {
|
|
111
|
+
if (!isNavigateKind(action)) return [];
|
|
112
|
+
const params = action.params ?? implicitParams;
|
|
113
|
+
return params === undefined ? [] : [toNavigateParamsSource(action, params, sourceScreenEntity)];
|
|
114
|
+
});
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
// Per target screen QN, the union of field names any navigate `params` may
|
package/src/http/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export
|
|
1
|
+
export type { EgressPolicy } from "@cosmicdrift/kumiko-http";
|
|
2
|
+
export { egress } from "@cosmicdrift/kumiko-http";
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,440 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
});
|
package/src/http/egress.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
assertAllowedHost,
|
|
3
|
-
assertHttpScheme,
|
|
4
|
-
type EgressPolicy,
|
|
5
|
-
type ResolvedHost,
|
|
6
|
-
resolvePublicHost,
|
|
7
|
-
} from "./policy";
|
|
8
|
-
|
|
9
|
-
const MAX_INTERNAL_REDIRECTS = 5;
|
|
10
|
-
|
|
11
|
-
// Bun's fetch() accepts a `tls.servername` request option that upstream
|
|
12
|
-
// bun-types does not declare — see buildPinnedRequest below for why it is
|
|
13
|
-
// needed (TLS SNI must stay pinned to the original hostname, not the
|
|
14
|
-
// resolved IP we connect to).
|
|
15
|
-
type PinnedRequestInit = RequestInit & { tls?: { servername: string } };
|
|
16
|
-
|
|
17
|
-
// Single exported way to speak outward. The policy is named once, at bind
|
|
18
|
-
// time, and cannot drift from the request that follows it.
|
|
19
|
-
export function egress(
|
|
20
|
-
policy: EgressPolicy,
|
|
21
|
-
): (raw: string, init?: RequestInit) => Promise<Response> {
|
|
22
|
-
return (raw, init) => runEgress(policy, raw, init);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function runEgress(
|
|
26
|
-
policy: EgressPolicy,
|
|
27
|
-
raw: string,
|
|
28
|
-
init: RequestInit | undefined,
|
|
29
|
-
): Promise<Response> {
|
|
30
|
-
const url = new URL(raw);
|
|
31
|
-
assertHttpScheme(url);
|
|
32
|
-
|
|
33
|
-
if (policy.kind === "internal") {
|
|
34
|
-
assertAllowedHost(url, policy.allowHosts);
|
|
35
|
-
return fetchWithAllowlistedRedirects(url, policy.allowHosts, init);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// external + tenant-supplied: deny private/reserved/link-local ranges,
|
|
39
|
-
// pin the connect to the exact address that passed that check (closes the
|
|
40
|
-
// DNS-rebinding TOCTOU, see buildPinnedRequest), and never follow a
|
|
41
|
-
// redirect — a 3xx could point anywhere, including back into the denied
|
|
42
|
-
// ranges above. The caller sees the 3xx response itself and, to follow it,
|
|
43
|
-
// is expected to call `egress()` again with the Location header — which
|
|
44
|
-
// re-runs this same resolve-and-pin check against the new host.
|
|
45
|
-
const resolved = await resolvePublicHost(url);
|
|
46
|
-
const pinned = buildPinnedRequest(url, resolved, init);
|
|
47
|
-
const res = await fetch(pinned.url, withManualRedirect(pinned.init));
|
|
48
|
-
return withOriginalUrl(res, url);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// fetch() reports `res.url` as the address it actually connected to — the
|
|
52
|
-
// pinned IP literal, not the hostname the caller asked for. Left alone, a
|
|
53
|
-
// caller resolving a relative Location header the normal way
|
|
54
|
-
// (`new URL(location, res.url)`) would resolve it against the IP instead of
|
|
55
|
-
// the original host, and the re-invoked egress() call would then pin and
|
|
56
|
-
// SNI-validate against that IP, breaking virtual hosting and TLS for
|
|
57
|
-
// essentially every real server. Overwriting `.url` back to the original
|
|
58
|
-
// request URL keeps that caller pattern working exactly as it would without
|
|
59
|
-
// IP pinning.
|
|
60
|
-
export function withOriginalUrl(res: Response, originalUrl: URL): Response {
|
|
61
|
-
Object.defineProperty(res, "url", { value: originalUrl.toString(), configurable: true });
|
|
62
|
-
return res;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Rewrites the request to connect directly to `resolved.address` — the
|
|
66
|
-
// exact address `resolvePublicHost` just validated — instead of handing
|
|
67
|
-
// `fetch()` the original hostname and letting it resolve again. That second
|
|
68
|
-
// resolution is the DNS-rebinding window: fetch-by-IP removes it entirely,
|
|
69
|
-
// since there is no hostname left for fetch to look up. The original
|
|
70
|
-
// hostname is preserved in the Host header (virtual hosting) and in
|
|
71
|
-
// `tls.servername` (TLS SNI, and certificate validation still checks the
|
|
72
|
-
// real hostname — not the IP we dial).
|
|
73
|
-
export function buildPinnedRequest(
|
|
74
|
-
url: URL,
|
|
75
|
-
resolved: ResolvedHost,
|
|
76
|
-
init: RequestInit | undefined,
|
|
77
|
-
): { url: URL; init: PinnedRequestInit } {
|
|
78
|
-
const pinnedHost = resolved.family === 6 ? `[${resolved.address}]` : resolved.address;
|
|
79
|
-
const pinnedUrl = new URL(url.toString());
|
|
80
|
-
pinnedUrl.hostname = pinnedHost;
|
|
81
|
-
if (pinnedUrl.hostname !== pinnedHost) {
|
|
82
|
-
// The WHATWG URL hostname setter silently no-ops on invalid input
|
|
83
|
-
// instead of throwing. If that ever happened here, the request would
|
|
84
|
-
// silently fall back to the original (unpinned) hostname — reopening
|
|
85
|
-
// the rebinding window this function exists to close. Fail loudly.
|
|
86
|
-
throw new Error(`egress: failed to pin connection to resolved address ${resolved.address}`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const headers = new Headers(init?.headers);
|
|
90
|
-
headers.set("host", url.host);
|
|
91
|
-
|
|
92
|
-
const pinnedInit: PinnedRequestInit = { ...init, headers };
|
|
93
|
-
if (url.protocol === "https:") {
|
|
94
|
-
pinnedInit.tls = { servername: url.hostname };
|
|
95
|
-
}
|
|
96
|
-
return { url: pinnedUrl, init: pinnedInit };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Applied last, after spreading the caller's `init` — a caller cannot
|
|
100
|
-
// override the policy's redirect handling by passing its own `redirect`
|
|
101
|
-
// option. Used by both the external/tenant-supplied fetch above and the
|
|
102
|
-
// internal redirect loop below, so it is not part of the package's public
|
|
103
|
-
// barrel (not re-exported from index.ts) but is a plain named export for
|
|
104
|
-
// that shared use and for pinning the invariant directly in
|
|
105
|
-
// ./__tests__/egress.test.ts.
|
|
106
|
-
export function withManualRedirect(init: RequestInit | undefined): RequestInit {
|
|
107
|
-
return { ...init, redirect: "manual" };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// `internal` is the only kind that permits redirects (it is the only kind
|
|
111
|
-
// with a host allowlist to re-validate them against). Each hop's Location
|
|
112
|
-
// header is resolved and checked against the same allowlist AND restricted
|
|
113
|
-
// to the current hop's host before it is followed, capped at
|
|
114
|
-
// MAX_INTERNAL_REDIRECTS to avoid an infinite loop.
|
|
115
|
-
// `init` (method, body, headers) is replayed verbatim on every hop — unlike
|
|
116
|
-
// a browser, this does not downgrade POST to GET on a 301/302/303. That is
|
|
117
|
-
// deliberate for an allowlisted internal target: a silent method change is
|
|
118
|
-
// its own class of surprise, and a streamed body would fail on the second
|
|
119
|
-
// hop either way. Replaying headers verbatim is also why redirects may not
|
|
120
|
-
// cross hosts (see below): a second allowlisted host must not receive the
|
|
121
|
-
// first host's Authorization/Cookie headers.
|
|
122
|
-
async function fetchWithAllowlistedRedirects(
|
|
123
|
-
url: URL,
|
|
124
|
-
allowHosts: readonly string[],
|
|
125
|
-
init: RequestInit | undefined,
|
|
126
|
-
): Promise<Response> {
|
|
127
|
-
let current = url;
|
|
128
|
-
for (let hop = 0; hop <= MAX_INTERNAL_REDIRECTS; hop++) {
|
|
129
|
-
const res = await fetch(current, withManualRedirect(init));
|
|
130
|
-
if (res.status < 300 || res.status >= 400) return res;
|
|
131
|
-
const location = res.headers.get("location");
|
|
132
|
-
if (!location) return res; // 3xx without Location — nothing to follow
|
|
133
|
-
|
|
134
|
-
let next: URL;
|
|
135
|
-
try {
|
|
136
|
-
next = new URL(location, current);
|
|
137
|
-
} catch {
|
|
138
|
-
throw new Error(
|
|
139
|
-
`egress(internal): redirect Location header is not a resolvable URL: ${location}`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
assertHttpScheme(next);
|
|
143
|
-
// `init.headers` (e.g. Authorization, Cookie) is replayed verbatim on
|
|
144
|
-
// every hop above. Without this check, one allowlisted host redirecting
|
|
145
|
-
// to a second, different allowlisted host would forward the caller's
|
|
146
|
-
// credentials to it — a classic redirect-credential-leak. Restricting
|
|
147
|
-
// hops to the same host keeps the realistic internal case (path
|
|
148
|
-
// rewrite, trailing slash) working while closing that leak.
|
|
149
|
-
if (next.hostname.toLowerCase() !== current.hostname.toLowerCase()) {
|
|
150
|
-
throw new Error(
|
|
151
|
-
`egress(internal): redirect crosses host (${current.hostname} -> ${next.hostname}), not followed`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
assertAllowedHost(next, allowHosts);
|
|
155
|
-
current = next;
|
|
156
|
-
}
|
|
157
|
-
throw new Error(`egress(internal): exceeded ${MAX_INTERNAL_REDIRECTS} redirects`);
|
|
158
|
-
}
|
package/src/http/policy.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// Egress trust boundary. `EgressPolicy` and the checks below back the single
|
|
2
|
-
// exported way to speak outward, `egress()` in ./egress.ts — this module and
|
|
3
|
-
// its exports stay off the package's public barrel (index.ts) on purpose:
|
|
4
|
-
// call sites bind a policy once via `egress(policy)` and never see the range
|
|
5
|
-
// table or the allowlist check directly.
|
|
6
|
-
//
|
|
7
|
-
// `external` and `tenant-supplied` resolve to the identical set of checks
|
|
8
|
-
// below (deny private/reserved/link-local + no redirects). They stay
|
|
9
|
-
// separate policy kinds because they carry different trust semantics — a
|
|
10
|
-
// tenant-controlled URL is adversary-input in a way a hardcoded external
|
|
11
|
-
// endpoint is not.
|
|
12
|
-
//
|
|
13
|
-
// DNS-rebinding protection: `resolvePublicHost` resolves the hostname
|
|
14
|
-
// exactly once, validates every returned address, and hands back the one
|
|
15
|
-
// address ./egress.ts connects to directly (fetch-by-IP, with the original
|
|
16
|
-
// hostname preserved for the Host header and TLS SNI/cert validation).
|
|
17
|
-
// There is no second resolution for an attacker-controlled DNS answer to
|
|
18
|
-
// swap in between check and connect — see `resolvePublicHost` below for the
|
|
19
|
-
// mechanism and ../__tests__/egress.test.ts / policy.test.ts for the tests
|
|
20
|
-
// pinning it.
|
|
21
|
-
|
|
22
|
-
import { lookup } from "node:dns/promises";
|
|
23
|
-
import { isIP } from "node:net";
|
|
24
|
-
|
|
25
|
-
export type EgressPolicy =
|
|
26
|
-
| { readonly kind: "external" }
|
|
27
|
-
| { readonly kind: "internal"; readonly allowHosts: readonly string[] }
|
|
28
|
-
| { readonly kind: "tenant-supplied" };
|
|
29
|
-
|
|
30
|
-
export function isBlockedIp(ip: string): boolean {
|
|
31
|
-
const kind = isIP(ip);
|
|
32
|
-
if (kind === 4) return isBlockedV4(ip);
|
|
33
|
-
if (kind === 6) return isBlockedV6(ip);
|
|
34
|
-
return true; // not parseable as an IP -> fail closed
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function inRange(n: number, [min, max]: readonly [number, number]): boolean {
|
|
38
|
-
return n >= min && n <= max;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Each entry: first-octet range, and an optional second-octet range for
|
|
42
|
-
// ranges narrower than a full /8. Table form keeps the range list scannable
|
|
43
|
-
// as data rather than as a chain of near-identical `if`s.
|
|
44
|
-
const BLOCKED_V4_RANGES: readonly {
|
|
45
|
-
readonly a: readonly [number, number];
|
|
46
|
-
readonly b?: readonly [number, number];
|
|
47
|
-
}[] = [
|
|
48
|
-
{ a: [0, 0] }, // 0.0.0.0/8 "this host"
|
|
49
|
-
{ a: [10, 10] }, // 10/8 private
|
|
50
|
-
{ a: [127, 127] }, // loopback
|
|
51
|
-
{ a: [169, 169], b: [254, 254] }, // link-local + cloud metadata (169.254.169.254)
|
|
52
|
-
{ a: [172, 172], b: [16, 31] }, // 172.16/12 private
|
|
53
|
-
{ a: [192, 192], b: [168, 168] }, // 192.168/16 private
|
|
54
|
-
{ a: [100, 100], b: [64, 127] }, // 100.64/10 CGNAT
|
|
55
|
-
{ a: [224, 255] }, // multicast/reserved
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
function isBlockedV4(ip: string): boolean {
|
|
59
|
-
const [a = 0, b = 0] = ip.split(".").map(Number);
|
|
60
|
-
return BLOCKED_V4_RANGES.some(
|
|
61
|
-
(range) => inRange(a, range.a) && (range.b === undefined || inRange(b, range.b)),
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// WHATWG URL host-parsing always serializes IPv6 into the canonical
|
|
66
|
-
// compressed lowercase form (RFC 5952) regardless of the input shape —
|
|
67
|
-
// uncompressed groups, embedded IPv4-dotted notation, mixed case all collapse
|
|
68
|
-
// to one string. isBlockedV6 below only has to handle that one shape, so any
|
|
69
|
-
// caller-supplied IPv6 literal is normalized through `new URL()` first.
|
|
70
|
-
function canonicalIPv6(ip: string): string {
|
|
71
|
-
try {
|
|
72
|
-
return new URL(`http://[${ip}]/`).hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
73
|
-
} catch {
|
|
74
|
-
return ip; // unparsable — falls through to the (fail-closed) checks below
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Two 16-bit hex groups, as WHATWG URL serializes an embedded IPv4 address
|
|
79
|
-
// inside an IPv6 literal, decoded back into the v4 range rules.
|
|
80
|
-
function isBlockedHexPair(hi: string | undefined, lo: string | undefined): boolean {
|
|
81
|
-
const hiNum = Number.parseInt(hi ?? "0", 16);
|
|
82
|
-
const loNum = Number.parseInt(lo ?? "0", 16);
|
|
83
|
-
return isBlockedV4(`${hiNum >>> 8}.${hiNum & 0xff}.${loNum >>> 8}.${loNum & 0xff}`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const BLOCKED_V6_EXACT = new Set(["::1", "::"]); // loopback / unspecified
|
|
87
|
-
|
|
88
|
-
// All three ranges expressed on the first 16-bit group ("head"), so they
|
|
89
|
-
// share one table instead of three near-identical `if`s:
|
|
90
|
-
// fc00::/7 unique-local -> head 0xfc00-0xfdff
|
|
91
|
-
// fe80::/10 link-local -> head 0xfe80-0xfebf
|
|
92
|
-
// fec0::/10 deprecated site-local (RFC 3879, 2004) — no DNS server hands
|
|
93
|
-
// this out today, but this is an exported security boundary,
|
|
94
|
-
// so block it rather than lean on the deprecation.
|
|
95
|
-
const BLOCKED_V6_HEAD_RANGES: readonly (readonly [number, number])[] = [
|
|
96
|
-
[0xfc00, 0xfdff],
|
|
97
|
-
[0xfe80, 0xfebf],
|
|
98
|
-
[0xfec0, 0xfeff],
|
|
99
|
-
];
|
|
100
|
-
|
|
101
|
-
function isBlockedV6(ip: string): boolean {
|
|
102
|
-
const lower = canonicalIPv6(ip.toLowerCase());
|
|
103
|
-
if (BLOCKED_V6_EXACT.has(lower)) return true;
|
|
104
|
-
const mappedDotted = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
105
|
-
if (mappedDotted) return isBlockedV4(mappedDotted[1] ?? "0.0.0.0"); // IPv4-mapped -> v4 rules
|
|
106
|
-
const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
107
|
-
if (mappedHex) return isBlockedHexPair(mappedHex[1], mappedHex[2]); // IPv4-mapped, hex form
|
|
108
|
-
// NAT64 well-known prefix (RFC 6052) embeds an IPv4 address in the low 32
|
|
109
|
-
// bits, same trick as the IPv4-mapped form above with a different prefix —
|
|
110
|
-
// a known SSRF-filter-bypass technique on NAT64/DNS64 networks.
|
|
111
|
-
const nat64 = lower.match(/^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
112
|
-
if (nat64) return isBlockedHexPair(nat64[1], nat64[2]);
|
|
113
|
-
// 6to4 (RFC 3056) embeds an IPv4 address directly after the 2002: prefix.
|
|
114
|
-
const sixToFour = lower.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})(?::|$)/);
|
|
115
|
-
if (sixToFour) return isBlockedHexPair(sixToFour[1], sixToFour[2]);
|
|
116
|
-
const head = Number.parseInt(lower.split(":")[0] || "0", 16);
|
|
117
|
-
return BLOCKED_V6_HEAD_RANGES.some((range) => inRange(head, range));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function stripBrackets(host: string): string {
|
|
121
|
-
return host.replace(/^\[/, "").replace(/\]$/, "");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function assertHttpScheme(url: URL): void {
|
|
125
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
126
|
-
throw new Error(`egress: unsupported scheme ${url.protocol}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export interface ResolvedHost {
|
|
131
|
-
readonly address: string;
|
|
132
|
-
readonly family: 4 | 6;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Denies private/reserved/link-local ranges for `external` and
|
|
136
|
-
// `tenant-supplied`, and pins the exact address ./egress.ts must connect to.
|
|
137
|
-
// Resolves the hostname's A/AAAA records (or reads the literal IP directly),
|
|
138
|
-
// rejects if ANY resolved address falls in a denied range — a host with one
|
|
139
|
-
// public and one private record must not pass — and returns ONE validated
|
|
140
|
-
// address from that same resolution. Reusing that address for the connect,
|
|
141
|
-
// instead of letting `fetch()` resolve the hostname again, is what closes
|
|
142
|
-
// the DNS-rebinding window: there is no second lookup left for an
|
|
143
|
-
// attacker-controlled DNS server to answer differently.
|
|
144
|
-
//
|
|
145
|
-
// `lookupFn` defaults to the real resolver and exists only so tests can pin
|
|
146
|
-
// deterministic, network-free answers — production call sites never pass it.
|
|
147
|
-
export async function resolvePublicHost(
|
|
148
|
-
url: URL,
|
|
149
|
-
lookupFn: typeof lookup = lookup,
|
|
150
|
-
): Promise<ResolvedHost> {
|
|
151
|
-
if (url.username || url.password) {
|
|
152
|
-
throw new Error("egress: URLs with embedded credentials are not supported");
|
|
153
|
-
}
|
|
154
|
-
const host = stripBrackets(url.hostname);
|
|
155
|
-
const ipVersion = isIP(host);
|
|
156
|
-
if (ipVersion !== 0) {
|
|
157
|
-
if (isBlockedIp(host)) throw new Error(`egress: host is not a public address: ${host}`);
|
|
158
|
-
return { address: host, family: ipVersion === 6 ? 6 : 4 };
|
|
159
|
-
}
|
|
160
|
-
let addresses: readonly { readonly address: string; readonly family: number }[];
|
|
161
|
-
try {
|
|
162
|
-
addresses = await lookupFn(host, { all: true });
|
|
163
|
-
} catch {
|
|
164
|
-
throw new Error(`egress: DNS resolution failed for host: ${host}`);
|
|
165
|
-
}
|
|
166
|
-
if (addresses.length === 0) {
|
|
167
|
-
throw new Error(`egress: DNS resolution returned no records for host: ${host}`);
|
|
168
|
-
}
|
|
169
|
-
const blocked = addresses.find((a) => isBlockedIp(a.address));
|
|
170
|
-
if (blocked) {
|
|
171
|
-
throw new Error(`egress: host resolves to a non-public address: ${host} -> ${blocked.address}`);
|
|
172
|
-
}
|
|
173
|
-
const [chosen] = addresses;
|
|
174
|
-
if (!chosen) {
|
|
175
|
-
throw new Error(`egress: DNS resolution returned no records for host: ${host}`);
|
|
176
|
-
}
|
|
177
|
-
return { address: chosen.address, family: chosen.family === 6 ? 6 : 4 };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Explicit allowlist check for `internal` — the hostname (not the resolved
|
|
181
|
-
// IP) must appear verbatim (case-insensitive) in `allowHosts`. No range
|
|
182
|
-
// check: `internal` targets are deliberately private/cluster-local.
|
|
183
|
-
// `allowHosts` is a host allowlist, not an origin allowlist: it is
|
|
184
|
-
// deliberately port- and scheme-agnostic (`svc.internal` also admits
|
|
185
|
-
// `svc.internal:9999` over http or https) — `EgressPolicy` doesn't carry a
|
|
186
|
-
// port or scheme in its shape, so there is nothing more specific to check.
|
|
187
|
-
export function assertAllowedHost(url: URL, allowHosts: readonly string[]): void {
|
|
188
|
-
const host = stripBrackets(url.hostname).toLowerCase();
|
|
189
|
-
const allowed = allowHosts.some((h) => h.toLowerCase() === host);
|
|
190
|
-
if (!allowed) {
|
|
191
|
-
throw new Error(`egress: host not in allowlist: ${host}`);
|
|
192
|
-
}
|
|
193
|
-
}
|