@cosmicdrift/kumiko-framework 0.201.0 → 0.203.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +7 -3
  2. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  3. package/src/api/__tests__/body-limit.test.ts +78 -4
  4. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  5. package/src/api/api-constants.ts +44 -7
  6. package/src/api/auth-middleware.ts +19 -3
  7. package/src/api/index.ts +1 -0
  8. package/src/api/route-registrars.ts +19 -22
  9. package/src/api/server.ts +1 -1
  10. package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
  11. package/src/db/__tests__/list-pagination.test.ts +28 -0
  12. package/src/db/dialect.ts +7 -8
  13. package/src/db/entity-table-meta.ts +4 -3
  14. package/src/db/event-store-executor-read.ts +26 -2
  15. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +22 -0
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
  18. package/src/engine/__tests__/build-app-schema.test.ts +119 -0
  19. package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
  20. package/src/engine/boot-validator/action-wiring.ts +7 -1
  21. package/src/engine/boot-validator/detail-screens.ts +35 -0
  22. package/src/engine/boot-validator/index.ts +4 -0
  23. package/src/engine/boot-validator/projection-list-screens.ts +82 -0
  24. package/src/engine/boot-validator/screens.ts +42 -1
  25. package/src/engine/build-app-schema.ts +55 -2
  26. package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
  27. package/src/engine/feature-ast/patch.ts +22 -2
  28. package/src/files/__tests__/files.integration.test.ts +2 -2
  29. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  30. package/src/http/__tests__/egress.test.ts +440 -0
  31. package/src/http/__tests__/policy.test.ts +125 -0
  32. package/src/http/egress.ts +158 -0
  33. package/src/http/index.ts +2 -0
  34. package/src/http/policy.ts +193 -0
@@ -0,0 +1,158 @@
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
+ }
@@ -0,0 +1,2 @@
1
+ export { egress } from "./egress";
2
+ export type { EgressPolicy } from "./policy";
@@ -0,0 +1,193 @@
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
+ }