@cosmicdrift/kumiko-framework 0.373.0 → 0.377.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/AGENTS.md +2 -2
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +3 -3
- package/agents/examples/compose-stacks.md +15 -8
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/notes-history-basic.md +1 -1
- package/agents/examples/tags-basic.md +1 -1
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/wizard-form.md +4 -4
- package/agents/widgets.md +4 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +2 -1
- package/dist/config/index.d.ts +4 -0
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +12 -4
- package/dist/db/money.js +1 -1
- package/dist/db/query.js +14 -14
- package/dist/db/replay-migration-sql.d.ts +1 -0
- package/dist/db/replay-migration-sql.js +115 -11
- package/dist/db/tenant-db.d.ts +7 -0
- package/dist/db/tenant-db.js +17 -5
- package/dist/engine/boot-validator/projection-list-screens.js +28 -37
- package/dist/engine/boot-validator/query-refs.js +26 -8
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +334 -42
- package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
- package/dist/engine/build-app-schema.js +7 -3
- package/dist/engine/entity-list-searchability.d.ts +5 -0
- package/dist/engine/entity-list-searchability.js +35 -0
- package/dist/engine/feature-ui-extensions.js +5 -2
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/paged-list.d.ts +29 -0
- package/dist/engine/paged-list.js +56 -0
- package/dist/engine/parse-ref-target.js +1 -1
- package/dist/engine/project-app-schema-for-roles.js +37 -6
- package/dist/engine/provider-plugin-context.d.ts +3 -0
- package/dist/engine/provider-plugin-context.js +28 -0
- package/dist/engine/schema-builder.js +7 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/env/index.js +1 -1
- package/dist/errors/zod-bridge.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/provider-resolver.d.ts +17 -17
- package/dist/files/provider-resolver.js +1 -1
- package/dist/http/egress.d.ts +14 -0
- package/dist/http/egress.js +144 -0
- package/dist/http/index.d.ts +3 -2
- package/dist/http/index.js +2 -1
- package/dist/http/policy.d.ts +30 -0
- package/dist/http/policy.js +299 -0
- package/dist/i18n/required-surface-keys.js +52 -2
- package/dist/jobs/job-runner.js +4 -2
- package/dist/pipeline/config-accessor-factory.d.ts +3 -0
- package/dist/pipeline/config-accessor-factory.js +3 -0
- package/dist/pipeline/dispatch-shared.js +7 -5
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/upgrade-changelog.d.ts +25 -0
- package/dist/upgrade-changelog.js +48 -0
- package/dist/upgrade-cli.js +78 -13
- package/package.json +3 -5
- package/presets/biome-app.json +8 -0
- package/src/changes.json +132 -0
- package/src/scripts/codemod/README.md +25 -0
- package/src/scripts/codemod/codemod-support.ts +64 -6
- package/src/scripts/codemod/package-merge.ts +260 -0
- package/src/scripts/codemod/s3-imports.ts +4 -42
- package/src/scripts/codemod/testing-injection.ts +135 -0
|
@@ -110,7 +110,7 @@ export function makeFileProviderResolver(deps) {
|
|
|
110
110
|
db: deps.db,
|
|
111
111
|
secrets: deps.secrets,
|
|
112
112
|
});
|
|
113
|
-
return createFileProviderForTenant({ config, registry: deps.registry, secrets: deps.secrets,
|
|
113
|
+
return createFileProviderForTenant({ config, registry: deps.registry, secrets: deps.secrets, auditUserId: SYSTEM_USER_ID }, tenantId);
|
|
114
114
|
})();
|
|
115
115
|
built.catch(() => {
|
|
116
116
|
if (cache.get(tenantId) === built)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type EgressPolicy, type ResolvedHost } from "./policy.ts";
|
|
2
|
+
type PinnedRequestInit = RequestInit & {
|
|
3
|
+
tls?: {
|
|
4
|
+
servername: string;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
export declare function egress(policy: EgressPolicy): (raw: string, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
export declare function withOriginalUrl(res: Response, originalUrl: URL): Response;
|
|
9
|
+
export declare function buildPinnedRequest(url: URL, resolved: ResolvedHost, init: RequestInit | undefined): {
|
|
10
|
+
url: URL;
|
|
11
|
+
init: PinnedRequestInit;
|
|
12
|
+
};
|
|
13
|
+
export declare function withManualRedirect(init: RequestInit | undefined): RequestInit;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assertAllowedTarget, assertHttpScheme, assertValidInternalPolicy, resolvePublicHost, } from "./policy.js";
|
|
2
|
+
const MAX_INTERNAL_REDIRECTS = 5;
|
|
3
|
+
// Single exported way to speak outward. The policy is named once, at bind
|
|
4
|
+
// time, and cannot drift from the request that follows it.
|
|
5
|
+
export function egress(policy) {
|
|
6
|
+
if (policy.kind === "internal")
|
|
7
|
+
assertValidInternalPolicy(policy);
|
|
8
|
+
return (raw, init) => runEgress(policy, raw, init);
|
|
9
|
+
}
|
|
10
|
+
async function runEgress(policy, raw, init) {
|
|
11
|
+
const url = new URL(raw);
|
|
12
|
+
assertHttpScheme(url);
|
|
13
|
+
if (policy.kind === "internal") {
|
|
14
|
+
assertAllowedTarget(url, policy);
|
|
15
|
+
return fetchWithAllowlistedRedirects(url, policy, init);
|
|
16
|
+
}
|
|
17
|
+
// external + tenant-supplied: deny private/reserved/link-local ranges,
|
|
18
|
+
// pin the connect to the exact address that passed that check (closes the
|
|
19
|
+
// DNS-rebinding TOCTOU, see buildPinnedRequest), and never follow a
|
|
20
|
+
// redirect — a 3xx could point anywhere, including back into the denied
|
|
21
|
+
// ranges above. The caller sees the 3xx response itself and, to follow it,
|
|
22
|
+
// is expected to call `egress()` again with the Location header — which
|
|
23
|
+
// re-runs this same resolve-and-pin check against the new host.
|
|
24
|
+
const resolved = await resolvePublicHost(url);
|
|
25
|
+
const pinned = buildPinnedRequest(url, resolved, init);
|
|
26
|
+
const res = await fetch(pinned.url, withManualRedirect(pinned.init));
|
|
27
|
+
return withOriginalUrl(res, url);
|
|
28
|
+
}
|
|
29
|
+
// fetch() reports `res.url` as the address it actually connected to — the
|
|
30
|
+
// pinned IP literal, not the hostname the caller asked for. Left alone, a
|
|
31
|
+
// caller resolving a relative Location header the normal way
|
|
32
|
+
// (`new URL(location, res.url)`) would resolve it against the IP instead of
|
|
33
|
+
// the original host, and the re-invoked egress() call would then pin and
|
|
34
|
+
// SNI-validate against that IP, breaking virtual hosting and TLS for
|
|
35
|
+
// essentially every real server. Overwriting `.url` back to the original
|
|
36
|
+
// request URL keeps that caller pattern working exactly as it would without
|
|
37
|
+
// IP pinning.
|
|
38
|
+
export function withOriginalUrl(res, originalUrl) {
|
|
39
|
+
Object.defineProperty(res, "url", { value: originalUrl.toString(), configurable: true });
|
|
40
|
+
return res;
|
|
41
|
+
}
|
|
42
|
+
// Rewrites the request to connect directly to `resolved.address` — the
|
|
43
|
+
// exact address `resolvePublicHost` just validated — instead of handing
|
|
44
|
+
// `fetch()` the original hostname and letting it resolve again. That second
|
|
45
|
+
// resolution is the DNS-rebinding window: fetch-by-IP removes it entirely,
|
|
46
|
+
// since there is no hostname left for fetch to look up. The original
|
|
47
|
+
// hostname is preserved in the Host header (virtual hosting) and in
|
|
48
|
+
// `tls.servername` (TLS SNI, and certificate validation still checks the
|
|
49
|
+
// real hostname — not the IP we dial).
|
|
50
|
+
export function buildPinnedRequest(url, resolved, init) {
|
|
51
|
+
const pinnedHost = resolved.family === 6 ? `[${resolved.address}]` : resolved.address;
|
|
52
|
+
const pinnedUrl = new URL(url.toString());
|
|
53
|
+
pinnedUrl.hostname = pinnedHost;
|
|
54
|
+
if (pinnedUrl.hostname !== pinnedHost) {
|
|
55
|
+
// The WHATWG URL hostname setter silently no-ops on invalid input
|
|
56
|
+
// instead of throwing. If that ever happened here, the request would
|
|
57
|
+
// silently fall back to the original (unpinned) hostname — reopening
|
|
58
|
+
// the rebinding window this function exists to close. Fail loudly.
|
|
59
|
+
throw new Error(`egress: failed to pin connection to resolved address ${resolved.address}`);
|
|
60
|
+
}
|
|
61
|
+
const headers = new Headers(init?.headers);
|
|
62
|
+
headers.set("host", url.host);
|
|
63
|
+
const pinnedInit = { ...init, headers };
|
|
64
|
+
if (url.protocol === "https:") {
|
|
65
|
+
pinnedInit.tls = { servername: url.hostname };
|
|
66
|
+
}
|
|
67
|
+
return { url: pinnedUrl, init: pinnedInit };
|
|
68
|
+
}
|
|
69
|
+
// Applied last, after spreading the caller's `init` — a caller cannot
|
|
70
|
+
// override the policy's redirect handling by passing its own `redirect`
|
|
71
|
+
// option. Used by both the external/tenant-supplied fetch above and the
|
|
72
|
+
// internal redirect loop below, so it is not part of the package's public
|
|
73
|
+
// barrel (not re-exported from index.ts) but is a plain named export for
|
|
74
|
+
// that shared use and for pinning the invariant directly in
|
|
75
|
+
// ./__tests__/egress.test.ts.
|
|
76
|
+
export function withManualRedirect(init) {
|
|
77
|
+
return { ...init, redirect: "manual" };
|
|
78
|
+
}
|
|
79
|
+
const ORIGIN_CHANGE_SAFE_HEADERS = new Set([
|
|
80
|
+
"content-type",
|
|
81
|
+
"accept",
|
|
82
|
+
"accept-encoding",
|
|
83
|
+
"accept-language",
|
|
84
|
+
"user-agent",
|
|
85
|
+
]);
|
|
86
|
+
// Allowlist, not denylist: any credential-bearing header we have not thought
|
|
87
|
+
// of (x-api-key, custom tokens) must not follow a redirect to another origin.
|
|
88
|
+
function stripToSafeHeaders(init) {
|
|
89
|
+
const kept = new Headers();
|
|
90
|
+
for (const [name, value] of new Headers(init?.headers)) {
|
|
91
|
+
if (ORIGIN_CHANGE_SAFE_HEADERS.has(name))
|
|
92
|
+
kept.set(name, value);
|
|
93
|
+
}
|
|
94
|
+
return { ...init, headers: kept };
|
|
95
|
+
}
|
|
96
|
+
// `internal` is the only kind that permits redirects (it is the only kind
|
|
97
|
+
// with an allowlist to re-validate them against). Each hop's Location is
|
|
98
|
+
// resolved, restricted to the current hop's host and checked against the
|
|
99
|
+
// policy before it is followed, capped at MAX_INTERNAL_REDIRECTS.
|
|
100
|
+
// `init` (method, body) is replayed verbatim on every hop — unlike a browser,
|
|
101
|
+
// this does not downgrade POST to GET on a 301/302/303. Headers are replayed
|
|
102
|
+
// only while the origin is unchanged; once a hop leaves the original origin
|
|
103
|
+
// (scheme or port differs), credential headers are dropped for the rest of the
|
|
104
|
+
// chain. The body is still replayed to the new origin (as fetch does on
|
|
105
|
+
// 307/308); use allowOrigins to forbid such hops entirely.
|
|
106
|
+
async function fetchWithAllowlistedRedirects(url, policy, init) {
|
|
107
|
+
let current = url;
|
|
108
|
+
let hopInit = init;
|
|
109
|
+
let strippedInit;
|
|
110
|
+
for (let hop = 0; hop <= MAX_INTERNAL_REDIRECTS; hop++) {
|
|
111
|
+
const res = await fetch(current, withManualRedirect(hopInit));
|
|
112
|
+
if (res.status < 300 || res.status >= 400)
|
|
113
|
+
return res;
|
|
114
|
+
const location = res.headers.get("location");
|
|
115
|
+
if (!location)
|
|
116
|
+
return res; // 3xx without Location — nothing to follow
|
|
117
|
+
let next;
|
|
118
|
+
try {
|
|
119
|
+
next = new URL(location, current);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`egress(internal): redirect Location header is not a resolvable URL: ${location}`);
|
|
123
|
+
}
|
|
124
|
+
assertHttpScheme(next);
|
|
125
|
+
// Hosts may differ in the allowlist; credentials must still not hop
|
|
126
|
+
// between them.
|
|
127
|
+
if (next.hostname.toLowerCase() !== current.hostname.toLowerCase()) {
|
|
128
|
+
throw new Error(`egress(internal): redirect crosses host (${current.hostname} -> ${next.hostname}), not followed`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
assertAllowedTarget(next, policy);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Prefix is the contract consumers match on (chat-webhook-sender); origins only, no path/query.
|
|
135
|
+
throw new Error(`egress(internal): redirect target not in allowlist (${current.origin} -> ${next.origin}), not followed`);
|
|
136
|
+
}
|
|
137
|
+
if (next.origin !== url.origin) {
|
|
138
|
+
strippedInit ??= stripToSafeHeaders(init);
|
|
139
|
+
hopInit = strippedInit;
|
|
140
|
+
}
|
|
141
|
+
current = next;
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`egress(internal): exceeded ${MAX_INTERNAL_REDIRECTS} redirects`);
|
|
144
|
+
}
|
package/dist/http/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export
|
|
2
|
-
export {
|
|
1
|
+
export { buildPinnedRequest, egress } from "./egress.ts";
|
|
2
|
+
export type { EgressPolicy, ResolvedHost } from "./policy.ts";
|
|
3
|
+
export { BlockedHostError, HostResolutionError, isPublicHost, resolvePublicHost, resolvePublicHostname, } from "./policy.ts";
|
package/dist/http/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { buildPinnedRequest, egress } from "./egress.js";
|
|
2
|
+
export { BlockedHostError, HostResolutionError, isPublicHost, resolvePublicHost, resolvePublicHostname, } from "./policy.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
export type EgressPolicy = {
|
|
3
|
+
readonly kind: "external";
|
|
4
|
+
} | {
|
|
5
|
+
readonly kind: "internal";
|
|
6
|
+
readonly allowHosts?: readonly string[];
|
|
7
|
+
readonly allowOrigins?: readonly string[];
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: "tenant-supplied";
|
|
10
|
+
};
|
|
11
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
12
|
+
export declare function assertHttpScheme(url: URL): void;
|
|
13
|
+
export interface ResolvedHost {
|
|
14
|
+
readonly address: string;
|
|
15
|
+
readonly family: 4 | 6;
|
|
16
|
+
}
|
|
17
|
+
export declare class BlockedHostError extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
export declare class HostResolutionError extends Error {
|
|
21
|
+
constructor(message: string);
|
|
22
|
+
}
|
|
23
|
+
export declare function resolvePublicHostname(rawHost: string, lookupFn?: typeof lookup): Promise<ResolvedHost>;
|
|
24
|
+
export declare function resolvePublicHost(url: URL, lookupFn?: typeof lookup): Promise<ResolvedHost>;
|
|
25
|
+
export declare function isPublicHost(raw: string, lookupFn?: typeof lookup): Promise<boolean>;
|
|
26
|
+
export type InternalEgressPolicy = Extract<EgressPolicy, {
|
|
27
|
+
kind: "internal";
|
|
28
|
+
}>;
|
|
29
|
+
export declare function assertValidInternalPolicy(policy: InternalEgressPolicy): void;
|
|
30
|
+
export declare function assertAllowedTarget(url: URL, policy: InternalEgressPolicy): void;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Egress trust boundary. `EgressPolicy` and the checks below back the single
|
|
2
|
+
// exported way to speak outward, `egress()` in ./egress.ts. Only `isPublicHost`
|
|
3
|
+
// reaches the package's public barrel (index.ts), as a boolean pre-check for
|
|
4
|
+
// create-time validation — call sites otherwise bind a policy once via
|
|
5
|
+
// `egress(policy)` and never see the range table or the allowlist check
|
|
6
|
+
// directly.
|
|
7
|
+
//
|
|
8
|
+
// `external` and `tenant-supplied` resolve to the identical set of checks
|
|
9
|
+
// below (deny private/reserved/link-local + no redirects). They stay
|
|
10
|
+
// separate policy kinds because they carry different trust semantics — a
|
|
11
|
+
// tenant-controlled URL is adversary-input in a way a hardcoded external
|
|
12
|
+
// endpoint is not.
|
|
13
|
+
//
|
|
14
|
+
// DNS-rebinding protection: `resolvePublicHost` resolves the hostname
|
|
15
|
+
// exactly once, validates every returned address, and hands back the one
|
|
16
|
+
// address ./egress.ts connects to directly (fetch-by-IP, with the original
|
|
17
|
+
// hostname preserved for the Host header and TLS SNI/cert validation).
|
|
18
|
+
// There is no second resolution for an attacker-controlled DNS answer to
|
|
19
|
+
// swap in between check and connect — see `resolvePublicHost` below for the
|
|
20
|
+
// mechanism and ../__tests__/egress.test.ts / policy.test.ts for the tests
|
|
21
|
+
// pinning it.
|
|
22
|
+
import { lookup } from "node:dns/promises";
|
|
23
|
+
import { isIP } from "node:net";
|
|
24
|
+
export function isBlockedIp(ip) {
|
|
25
|
+
const kind = isIP(ip);
|
|
26
|
+
if (kind === 4)
|
|
27
|
+
return isBlockedV4(ip);
|
|
28
|
+
if (kind === 6)
|
|
29
|
+
return isBlockedV6(ip);
|
|
30
|
+
return true; // not parseable as an IP -> fail closed
|
|
31
|
+
}
|
|
32
|
+
function inRange(n, [min, max]) {
|
|
33
|
+
return n >= min && n <= max;
|
|
34
|
+
}
|
|
35
|
+
// Each entry: first-octet range, an optional second-octet range for ranges
|
|
36
|
+
// narrower than a full /8, and an optional third-octet range for the one
|
|
37
|
+
// entry (192.0.0.0/24) narrower than a full /16. Table form keeps the range
|
|
38
|
+
// list scannable as data rather than as a chain of near-identical `if`s.
|
|
39
|
+
const BLOCKED_V4_RANGES = [
|
|
40
|
+
{ a: [0, 0] }, // 0.0.0.0/8 "this host"
|
|
41
|
+
{ a: [10, 10] }, // 10/8 private
|
|
42
|
+
{ a: [127, 127] }, // loopback
|
|
43
|
+
{ a: [169, 169], b: [254, 254] }, // link-local + cloud metadata (169.254.169.254)
|
|
44
|
+
{ a: [172, 172], b: [16, 31] }, // 172.16/12 private
|
|
45
|
+
{ a: [192, 192], b: [0, 0], c: [0, 0] }, // 192.0.0.0/24 IETF protocol assignments
|
|
46
|
+
{ a: [192, 192], b: [168, 168] }, // 192.168/16 private
|
|
47
|
+
{ a: [198, 198], b: [18, 19] }, // 198.18.0.0/15 benchmarking (RFC 2544)
|
|
48
|
+
{ a: [100, 100], b: [64, 127] }, // 100.64/10 CGNAT
|
|
49
|
+
{ a: [224, 255] }, // multicast/reserved — also covers 240.0.0.0/4 and the broadcast address
|
|
50
|
+
];
|
|
51
|
+
function isBlockedV4(ip) {
|
|
52
|
+
const [a = 0, b = 0, c = 0] = ip.split(".").map(Number);
|
|
53
|
+
return BLOCKED_V4_RANGES.some((range) => inRange(a, range.a) &&
|
|
54
|
+
(range.b === undefined || inRange(b, range.b)) &&
|
|
55
|
+
(range.c === undefined || inRange(c, range.c)));
|
|
56
|
+
}
|
|
57
|
+
// WHATWG URL host-parsing always serializes IPv6 into the canonical
|
|
58
|
+
// compressed lowercase form (RFC 5952) regardless of the input shape —
|
|
59
|
+
// uncompressed groups, embedded IPv4-dotted notation, mixed case all collapse
|
|
60
|
+
// to one string. isBlockedV6 below only has to handle that one shape, so any
|
|
61
|
+
// caller-supplied IPv6 literal is normalized through `new URL()` first.
|
|
62
|
+
function canonicalIPv6(ip) {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(`http://[${ip}]/`).hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return ip; // unparsable — falls through to the (fail-closed) checks below
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const HEX_GROUP_RE = /^[0-9a-f]{1,4}$/;
|
|
71
|
+
// Expands a canonicalized (RFC 5952) IPv6 literal into its 8 explicit
|
|
72
|
+
// 16-bit groups. Compression ("::") swallows a variable number of the
|
|
73
|
+
// trailing zero groups an embedded IPv4 address produces — e.g.
|
|
74
|
+
// `64:ff9b::0.0.0.1` canonicalizes to `64:ff9b::1` (one group, not two) —
|
|
75
|
+
// so matching by regex on the compressed string misses those forms.
|
|
76
|
+
// Expanding first lets every embedded-v4 check below match by fixed group
|
|
77
|
+
// *position* regardless of how much of the address compressed away.
|
|
78
|
+
// Returns null for anything that isn't well-formed hex-group IPv6 (e.g. a
|
|
79
|
+
// zone id), which isBlockedV6 treats as fail-closed.
|
|
80
|
+
function expandGroups(ip) {
|
|
81
|
+
const parts = ip.split("::");
|
|
82
|
+
if (parts.length > 2)
|
|
83
|
+
return null;
|
|
84
|
+
const toGroups = (segment) => (segment ? segment.split(":") : []);
|
|
85
|
+
const head = toGroups(parts[0]);
|
|
86
|
+
if (parts.length === 1) {
|
|
87
|
+
if (head.length !== 8 || !head.every((g) => HEX_GROUP_RE.test(g)))
|
|
88
|
+
return null;
|
|
89
|
+
return head.map((g) => Number.parseInt(g, 16));
|
|
90
|
+
}
|
|
91
|
+
const tail = toGroups(parts[1]);
|
|
92
|
+
const missing = 8 - head.length - tail.length;
|
|
93
|
+
if (missing < 0 || ![...head, ...tail].every((g) => HEX_GROUP_RE.test(g)))
|
|
94
|
+
return null;
|
|
95
|
+
return [...head, ...Array(missing).fill("0"), ...tail].map((g) => Number.parseInt(g, 16));
|
|
96
|
+
}
|
|
97
|
+
// Decodes the two 16-bit groups at `at`/`at+1` back into dotted-decimal, for
|
|
98
|
+
// the v4 range rules.
|
|
99
|
+
function decodeEmbeddedV4(groups, at) {
|
|
100
|
+
const hi = groups[at] ?? 0;
|
|
101
|
+
const lo = groups[at + 1] ?? 0;
|
|
102
|
+
return `${hi >>> 8}.${hi & 0xff}.${lo >>> 8}.${lo & 0xff}`;
|
|
103
|
+
}
|
|
104
|
+
// Every "IPv4-in-IPv6" encoding a URL literal can carry, as the fixed
|
|
105
|
+
// leading groups (in expanded, 8-group form) and the group index the
|
|
106
|
+
// embedded IPv4 address starts at. Matching by expanded position instead of
|
|
107
|
+
// regex on the compressed string is what makes a single-group-compressed
|
|
108
|
+
// embed (see expandGroups above) match the same as an uncompressed one.
|
|
109
|
+
const EMBEDDED_V4_FORMS = [
|
|
110
|
+
{ prefix: [0, 0, 0, 0, 0, 0], at: 6 }, // ::a.b.c.d — deprecated IPv4-compatible (RFC 4291)
|
|
111
|
+
{ prefix: [0, 0, 0, 0, 0, 0xffff], at: 6 }, // ::ffff:a.b.c.d — IPv4-mapped (RFC 4291)
|
|
112
|
+
{ prefix: [0, 0, 0, 0, 0xffff, 0], at: 6 }, // ::ffff:0:a.b.c.d — IPv4-translated (RFC 8215)
|
|
113
|
+
{ prefix: [0x64, 0xff9b, 0, 0, 0, 0], at: 6 }, // 64:ff9b::a.b.c.d — NAT64 well-known prefix (RFC 6052)
|
|
114
|
+
{ prefix: [0x64, 0xff9b, 1], at: 6 }, // 64:ff9b:1::a.b.c.d — NAT64 local-use prefix (RFC 8215)
|
|
115
|
+
{ prefix: [0x2002], at: 1 }, // 2002:a.b.c.d:: — 6to4 (RFC 3056)
|
|
116
|
+
];
|
|
117
|
+
function matchesPrefix(groups, prefix) {
|
|
118
|
+
return prefix.every((value, index) => groups[index] === value);
|
|
119
|
+
}
|
|
120
|
+
const BLOCKED_V6_EXACT = new Set(["::1", "::"]); // loopback / unspecified
|
|
121
|
+
// All three ranges expressed on the first 16-bit group ("head"), so they
|
|
122
|
+
// share one table instead of three near-identical `if`s:
|
|
123
|
+
// fc00::/7 unique-local -> head 0xfc00-0xfdff
|
|
124
|
+
// fe80::/10 link-local -> head 0xfe80-0xfebf
|
|
125
|
+
// fec0::/10 deprecated site-local (RFC 3879, 2004) — no DNS server hands
|
|
126
|
+
// this out today, but this is an exported security boundary,
|
|
127
|
+
// so block it rather than lean on the deprecation.
|
|
128
|
+
const BLOCKED_V6_HEAD_RANGES = [
|
|
129
|
+
[0xfc00, 0xfdff],
|
|
130
|
+
[0xfe80, 0xfebf],
|
|
131
|
+
[0xfec0, 0xfeff],
|
|
132
|
+
];
|
|
133
|
+
function isBlockedV6(ip) {
|
|
134
|
+
const lower = canonicalIPv6(ip.toLowerCase());
|
|
135
|
+
if (BLOCKED_V6_EXACT.has(lower))
|
|
136
|
+
return true;
|
|
137
|
+
const groups = expandGroups(lower);
|
|
138
|
+
if (!groups)
|
|
139
|
+
return true; // not well-formed IPv6 -> fail closed
|
|
140
|
+
const embedded = EMBEDDED_V4_FORMS.find((form) => matchesPrefix(groups, form.prefix));
|
|
141
|
+
if (embedded)
|
|
142
|
+
return isBlockedV4(decodeEmbeddedV4(groups, embedded.at));
|
|
143
|
+
const head = groups[0] ?? 0;
|
|
144
|
+
return BLOCKED_V6_HEAD_RANGES.some((range) => inRange(head, range));
|
|
145
|
+
}
|
|
146
|
+
function stripBrackets(host) {
|
|
147
|
+
return host.replace(/^\[/, "").replace(/\]$/, "");
|
|
148
|
+
}
|
|
149
|
+
export function assertHttpScheme(url) {
|
|
150
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
151
|
+
throw new Error(`egress: unsupported scheme ${url.protocol}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Denies private/reserved/link-local ranges for `external` and
|
|
155
|
+
// `tenant-supplied`, and pins the exact address ./egress.ts must connect to.
|
|
156
|
+
// Resolves the hostname's A/AAAA records (or reads the literal IP directly),
|
|
157
|
+
// rejects if ANY resolved address falls in a denied range — a host with one
|
|
158
|
+
// public and one private record must not pass — and returns ONE validated
|
|
159
|
+
// address from that same resolution. Reusing that address for the connect,
|
|
160
|
+
// instead of letting `fetch()` resolve the hostname again, is what closes
|
|
161
|
+
// the DNS-rebinding window: there is no second lookup left for an
|
|
162
|
+
// attacker-controlled DNS server to answer differently.
|
|
163
|
+
//
|
|
164
|
+
// Thrown for a host that is itself a blocked IP-literal, or that resolves to
|
|
165
|
+
// one — a policy verdict, not a network condition. Retrying will not help;
|
|
166
|
+
// callers should surface this as a rejected configuration.
|
|
167
|
+
export class BlockedHostError extends Error {
|
|
168
|
+
constructor(message) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.name = "BlockedHostError";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Thrown when the resolver itself failed or returned nothing — a network
|
|
174
|
+
// condition, not a policy verdict. Callers that retry (e.g. an inbound-mail
|
|
175
|
+
// poll) should treat this as transient, unlike BlockedHostError.
|
|
176
|
+
export class HostResolutionError extends Error {
|
|
177
|
+
constructor(message) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.name = "HostResolutionError";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Resolves a bare hostname or IP-literal (no scheme, no URL parsing) exactly
|
|
183
|
+
// once and returns the single validated address the caller must connect to
|
|
184
|
+
// — the same DNS-rebinding-closing mechanism as resolvePublicHost below,
|
|
185
|
+
// factored out for callers that only ever have a host (SMTP/IMAP tenant
|
|
186
|
+
// config), never a full URL.
|
|
187
|
+
//
|
|
188
|
+
// `lookupFn` defaults to the real resolver and exists only so tests can pin
|
|
189
|
+
// deterministic, network-free answers — production call sites never pass it.
|
|
190
|
+
export async function resolvePublicHostname(rawHost, lookupFn = lookup) {
|
|
191
|
+
const host = stripBrackets(rawHost);
|
|
192
|
+
const ipVersion = isIP(host);
|
|
193
|
+
if (ipVersion !== 0) {
|
|
194
|
+
if (isBlockedIp(host))
|
|
195
|
+
throw new BlockedHostError(`egress: host is not a public address: ${host}`);
|
|
196
|
+
return { address: host, family: ipVersion === 6 ? 6 : 4 };
|
|
197
|
+
}
|
|
198
|
+
let addresses;
|
|
199
|
+
try {
|
|
200
|
+
addresses = await lookupFn(host, { all: true });
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new HostResolutionError(`egress: DNS resolution failed for host: ${host}`);
|
|
204
|
+
}
|
|
205
|
+
if (addresses.length === 0) {
|
|
206
|
+
throw new HostResolutionError(`egress: DNS resolution returned no records for host: ${host}`);
|
|
207
|
+
}
|
|
208
|
+
const blocked = addresses.find((a) => isBlockedIp(a.address));
|
|
209
|
+
if (blocked) {
|
|
210
|
+
throw new BlockedHostError(`egress: host resolves to a non-public address: ${host} -> ${blocked.address}`);
|
|
211
|
+
}
|
|
212
|
+
// IPv4 first: hosts and runners without an IPv6 route would otherwise get an unreachable pinned address.
|
|
213
|
+
const chosen = addresses.find((address) => address.family === 4) ?? addresses[0];
|
|
214
|
+
if (!chosen) {
|
|
215
|
+
throw new HostResolutionError(`egress: DNS resolution returned no records for host: ${host}`);
|
|
216
|
+
}
|
|
217
|
+
return { address: chosen.address, family: chosen.family === 6 ? 6 : 4 };
|
|
218
|
+
}
|
|
219
|
+
export async function resolvePublicHost(url, lookupFn = lookup) {
|
|
220
|
+
if (url.username || url.password) {
|
|
221
|
+
throw new Error("egress: URLs with embedded credentials are not supported");
|
|
222
|
+
}
|
|
223
|
+
return resolvePublicHostname(url.hostname, lookupFn);
|
|
224
|
+
}
|
|
225
|
+
// Create-time validation for tenant-supplied URLs (e.g. webhook registration)
|
|
226
|
+
// where rejecting at registration is better UX than failing later at fetch
|
|
227
|
+
// time. Runs the same resolution as `egress()` without connecting anywhere,
|
|
228
|
+
// and never throws — bad input (unparsable URL, non-http(s) scheme, embedded
|
|
229
|
+
// credentials, DNS failure, blocked range) all just resolve to `false`.
|
|
230
|
+
//
|
|
231
|
+
// A `true` only holds at the moment of the check: DNS can change afterwards,
|
|
232
|
+
// so this is a UX pre-check, not a substitute for the fetch-time check.
|
|
233
|
+
// Outbound requests to tenant-supplied URLs MUST still go through
|
|
234
|
+
// `egress({ kind: "tenant-supplied" })`, never a plain `fetch()`.
|
|
235
|
+
export async function isPublicHost(raw, lookupFn = lookup) {
|
|
236
|
+
try {
|
|
237
|
+
const url = new URL(raw);
|
|
238
|
+
assertHttpScheme(url);
|
|
239
|
+
await resolvePublicHost(url, lookupFn);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function isHostEntryWellFormed(entry) {
|
|
247
|
+
if (entry.includes("/") || entry.includes("@") || entry.includes("://"))
|
|
248
|
+
return false;
|
|
249
|
+
if (!entry.includes(":"))
|
|
250
|
+
return true;
|
|
251
|
+
return isIP(stripBrackets(entry)) === 6;
|
|
252
|
+
}
|
|
253
|
+
function isOriginEntryWellFormed(entry) {
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = new URL(entry);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return ((parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
262
|
+
!parsed.username &&
|
|
263
|
+
!parsed.password &&
|
|
264
|
+
parsed.pathname === "/" &&
|
|
265
|
+
!parsed.search &&
|
|
266
|
+
!parsed.hash);
|
|
267
|
+
}
|
|
268
|
+
// Runs once at bind time so a misconfigured allowlist fails at startup, not
|
|
269
|
+
// on the first request.
|
|
270
|
+
export function assertValidInternalPolicy(policy) {
|
|
271
|
+
const allowHosts = policy.allowHosts ?? [];
|
|
272
|
+
const allowOrigins = policy.allowOrigins ?? [];
|
|
273
|
+
if (allowHosts.length === 0 && allowOrigins.length === 0) {
|
|
274
|
+
throw new Error("egress(internal): allowHosts or allowOrigins must list at least one entry");
|
|
275
|
+
}
|
|
276
|
+
for (const entry of allowHosts) {
|
|
277
|
+
if (!isHostEntryWellFormed(entry)) {
|
|
278
|
+
throw new Error(`egress(internal): allowHosts entry must be a bare hostname, got "${entry}" (use allowOrigins to pin scheme and port)`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// The entry is named by index only: it may carry credentials that must not reach startup logs.
|
|
282
|
+
allowOrigins.forEach((entry, index) => {
|
|
283
|
+
if (!isOriginEntryWellFormed(entry)) {
|
|
284
|
+
throw new Error(`egress(internal): allowOrigins[${index}] must be an http(s) origin like "https://host:port" without path, query or credentials`);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
// Explicit allowlist check for `internal` — no range check, since `internal`
|
|
289
|
+
// targets are deliberately private/cluster-local. The hostname (not the
|
|
290
|
+
// resolved IP) must match `allowHosts` (any port/scheme) or the full origin
|
|
291
|
+
// (scheme + host + port) must match `allowOrigins`.
|
|
292
|
+
export function assertAllowedTarget(url, policy) {
|
|
293
|
+
const host = stripBrackets(url.hostname).toLowerCase();
|
|
294
|
+
const hostAllowed = (policy.allowHosts ?? []).some((entry) => stripBrackets(entry).toLowerCase() === host);
|
|
295
|
+
const originAllowed = (policy.allowOrigins ?? []).some((entry) => new URL(entry).origin === url.origin);
|
|
296
|
+
if (!hostAllowed && !originAllowed) {
|
|
297
|
+
throw new Error(`egress: target not in allowlist: ${url.origin}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -45,6 +45,19 @@ function pushKey(out, value, treatAsKey = false) {
|
|
|
45
45
|
function editFieldName(f) {
|
|
46
46
|
return typeof f === "string" ? f : f.field;
|
|
47
47
|
}
|
|
48
|
+
// Placeholder, affix texts and the fill button label of a field spec; a `{ field }`
|
|
49
|
+
// affix reads another form field and is no key.
|
|
50
|
+
function pushEditFieldTextKeys(out, spec, treatAsKey = false) {
|
|
51
|
+
// skip: shorthand field name carries no texts
|
|
52
|
+
if (typeof spec === "string")
|
|
53
|
+
return;
|
|
54
|
+
pushKey(out, spec.placeholder, treatAsKey);
|
|
55
|
+
for (const affix of [spec.prefix, spec.suffix]) {
|
|
56
|
+
if (typeof affix === "string")
|
|
57
|
+
pushKey(out, affix, treatAsKey);
|
|
58
|
+
}
|
|
59
|
+
pushKey(out, spec.fillAction?.label, treatAsKey);
|
|
60
|
+
}
|
|
48
61
|
// Group titles are translated exactly like the section title (computeEditViewModel).
|
|
49
62
|
function pushSectionTitles(out, section, treatAsKey = false) {
|
|
50
63
|
pushKey(out, section.title, treatAsKey);
|
|
@@ -67,15 +80,20 @@ function pushActionFormShapedKeys(out, featureName, step) {
|
|
|
67
80
|
pushSectionTitles(out, section);
|
|
68
81
|
for (const f of sectionFieldSpecs(section)) {
|
|
69
82
|
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, editFieldName(f)));
|
|
83
|
+
pushEditFieldTextKeys(out, f);
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
function pushRowActionKeys(out, action) {
|
|
74
88
|
pushKey(out, action.label);
|
|
75
|
-
if (action.kind === "writeHandler" ||
|
|
89
|
+
if (action.kind === "writeHandler" ||
|
|
90
|
+
action.kind === "clientAction" ||
|
|
91
|
+
action.kind === undefined) {
|
|
76
92
|
pushKey(out, action.confirm);
|
|
77
93
|
pushKey(out, action.confirmLabel);
|
|
78
94
|
}
|
|
95
|
+
if (action.kind === "clientAction")
|
|
96
|
+
pushKey(out, action.successMessage);
|
|
79
97
|
if (action.kind === "drawer") {
|
|
80
98
|
pushKey(out, action.title);
|
|
81
99
|
pushKey(out, action.subtitle);
|
|
@@ -90,6 +108,8 @@ function pushDashboardScreenKeys(out, dashboard) {
|
|
|
90
108
|
pushKey(out, option.label);
|
|
91
109
|
pushKey(out, dashboard.scope?.badge);
|
|
92
110
|
pushKey(out, dashboard.scope?.notice);
|
|
111
|
+
for (const action of dashboard.actions ?? [])
|
|
112
|
+
pushRowActionKeys(out, action);
|
|
93
113
|
}
|
|
94
114
|
function pushDashboardPanelKeys(out, panel) {
|
|
95
115
|
// skip: custom-Panel übersetzt sich selbst, kein Key hier
|
|
@@ -98,8 +118,12 @@ function pushDashboardPanelKeys(out, panel) {
|
|
|
98
118
|
pushKey(out, panel.label);
|
|
99
119
|
if (panel.kind === "chart" || panel.kind === "stat-group")
|
|
100
120
|
pushKey(out, panel.subtitle);
|
|
101
|
-
if (panel.kind === "
|
|
121
|
+
if (panel.kind === "text")
|
|
122
|
+
pushDashboardTextBlockKeys(out, panel);
|
|
123
|
+
if (panel.kind === "chart") {
|
|
124
|
+
pushKey(out, panel.unit);
|
|
102
125
|
pushDashboardChartRangeKeys(out, panel);
|
|
126
|
+
}
|
|
103
127
|
if (panel.kind === "chart" ||
|
|
104
128
|
panel.kind === "list" ||
|
|
105
129
|
panel.kind === "feed" ||
|
|
@@ -107,6 +131,9 @@ function pushDashboardPanelKeys(out, panel) {
|
|
|
107
131
|
pushKey(out, panel.emptyLabel);
|
|
108
132
|
pushKey(out, panel.emptyHint);
|
|
109
133
|
}
|
|
134
|
+
pushDashboardChildLabelKeys(out, panel);
|
|
135
|
+
}
|
|
136
|
+
function pushDashboardChildLabelKeys(out, panel) {
|
|
110
137
|
if (panel.kind === "stat-group") {
|
|
111
138
|
for (const stat of panel.stats)
|
|
112
139
|
pushKey(out, stat.label);
|
|
@@ -119,6 +146,20 @@ function pushDashboardPanelKeys(out, panel) {
|
|
|
119
146
|
}
|
|
120
147
|
}
|
|
121
148
|
}
|
|
149
|
+
function pushDashboardTextBlockKeys(out, panel) {
|
|
150
|
+
for (const block of panel.blocks) {
|
|
151
|
+
// skip: code is shown verbatim, not translated
|
|
152
|
+
if (block.kind === "code")
|
|
153
|
+
continue;
|
|
154
|
+
if (block.kind === "list") {
|
|
155
|
+
for (const item of block.items)
|
|
156
|
+
pushKey(out, item);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
pushKey(out, block.text);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
122
163
|
function pushDashboardChartRangeKeys(out, panel) {
|
|
123
164
|
for (const option of panel.ranges?.options ?? [])
|
|
124
165
|
pushKey(out, option.label);
|
|
@@ -243,6 +284,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
243
284
|
continue; // rejected at boot, unreachable here
|
|
244
285
|
pushSectionTitles(out, section);
|
|
245
286
|
for (const f of sectionFieldSpecs(section)) {
|
|
287
|
+
pushEditFieldTextKeys(out, f);
|
|
246
288
|
const fieldName = editFieldName(f);
|
|
247
289
|
const override = edit.fieldLabels?.[fieldName];
|
|
248
290
|
if (override !== undefined)
|
|
@@ -260,6 +302,10 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
260
302
|
pushKey(out, form.summary?.subtitle);
|
|
261
303
|
for (const action of form.footerActions ?? [])
|
|
262
304
|
pushKey(out, action.label);
|
|
305
|
+
for (const link of form.successLinks ?? [])
|
|
306
|
+
pushKey(out, link.label);
|
|
307
|
+
for (const fact of form.successFacts ?? [])
|
|
308
|
+
pushKey(out, fact.label);
|
|
263
309
|
for (const fieldName of Object.keys(form.fields)) {
|
|
264
310
|
const override = form.fieldLabels?.[fieldName];
|
|
265
311
|
if (override !== undefined)
|
|
@@ -276,6 +322,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
276
322
|
continue; // rejected at boot, unreachable here
|
|
277
323
|
pushSectionTitles(out, section);
|
|
278
324
|
for (const f of sectionFieldSpecs(section)) {
|
|
325
|
+
pushEditFieldTextKeys(out, f);
|
|
279
326
|
const fieldName = editFieldName(f);
|
|
280
327
|
const override = form.fieldLabels?.[fieldName];
|
|
281
328
|
if (override !== undefined)
|
|
@@ -328,6 +375,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
328
375
|
pushSectionTitles(out, section, treatDotFormAsKey);
|
|
329
376
|
pushKey(out, section.description, treatDotFormAsKey);
|
|
330
377
|
for (const f of sectionFieldSpecs(section)) {
|
|
378
|
+
pushEditFieldTextKeys(out, f, treatDotFormAsKey);
|
|
331
379
|
const fieldName = editFieldName(f);
|
|
332
380
|
const override = config.fieldLabels?.[fieldName];
|
|
333
381
|
if (override !== undefined)
|
|
@@ -366,6 +414,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
366
414
|
pushKey(out, section.submitLabel);
|
|
367
415
|
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
368
416
|
for (const f of section.fields) {
|
|
417
|
+
pushEditFieldTextKeys(out, f);
|
|
369
418
|
const fieldName = editFieldName(f);
|
|
370
419
|
out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
|
|
371
420
|
}
|
|
@@ -373,6 +422,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
373
422
|
}
|
|
374
423
|
pushSectionTitles(out, section);
|
|
375
424
|
for (const f of sectionFieldSpecs(section)) {
|
|
425
|
+
pushEditFieldTextKeys(out, f);
|
|
376
426
|
const fieldName = editFieldName(f);
|
|
377
427
|
const override = detail.fieldLabels?.[fieldName];
|
|
378
428
|
if (override !== undefined)
|