@appstrate/afps-shared 0.1.0 → 0.2.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 +9 -3
- package/src/backoff.ts +46 -0
- package/src/delivery-http.ts +3 -4
- package/src/file-field.ts +63 -0
- package/src/guarded-fetch.ts +230 -0
- package/src/ssrf-dns.ts +103 -0
- package/src/ssrf.ts +35 -2
- package/src/unzip-bounded.ts +148 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -40,11 +40,17 @@
|
|
|
40
40
|
"./integrity": "./src/integrity.ts",
|
|
41
41
|
"./credential-template": "./src/credential-template.ts",
|
|
42
42
|
"./delivery-http": "./src/delivery-http.ts",
|
|
43
|
+
"./file-field": "./src/file-field.ts",
|
|
43
44
|
"./ssrf": "./src/ssrf.ts",
|
|
44
|
-
"./token-usage": "./src/token-usage.ts"
|
|
45
|
+
"./token-usage": "./src/token-usage.ts",
|
|
46
|
+
"./ssrf-dns": "./src/ssrf-dns.ts",
|
|
47
|
+
"./guarded-fetch": "./src/guarded-fetch.ts",
|
|
48
|
+
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
49
|
+
"./backoff": "./src/backoff.ts"
|
|
45
50
|
},
|
|
46
51
|
"dependencies": {
|
|
47
|
-
"
|
|
52
|
+
"fflate": "^0.8.3",
|
|
53
|
+
"semver": "^7.8.4"
|
|
48
54
|
},
|
|
49
55
|
"devDependencies": {
|
|
50
56
|
"@types/semver": "^7.7.1"
|
package/src/backoff.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared retry primitives for the isolated runtime packages
|
|
5
|
+
* (`afps-runtime` sinks, `runtime-pi` provisioning). One place for the
|
|
6
|
+
* exponential-backoff arithmetic and the "is this HTTP status worth
|
|
7
|
+
* retrying" rule, so the policies can't silently drift between copies.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT adopted by `mcp-transport` (deadline-clamped full
|
|
10
|
+
* jitter interwoven with its abort handling) or the CLI's `api retry`
|
|
11
|
+
* (curl semantics: `Retry-After` honouring, 408 in the retryable set) —
|
|
12
|
+
* those are different, documented policies, not duplicates.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface BackoffOptions {
|
|
16
|
+
/** Delay before the second attempt (attempt 1 retry), in ms. */
|
|
17
|
+
baseMs: number;
|
|
18
|
+
/** Upper bound on the exponential term, in ms. */
|
|
19
|
+
capMs: number;
|
|
20
|
+
/**
|
|
21
|
+
* Additive jitter as a fraction of the (capped) exponential delay:
|
|
22
|
+
* `0.25` adds up to +25%. Defaults to 0 (no jitter).
|
|
23
|
+
*/
|
|
24
|
+
jitterRatio?: number;
|
|
25
|
+
/** Injectable RNG for deterministic tests. Defaults to `Math.random`. */
|
|
26
|
+
random?: () => number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Exponential backoff delay for a 1-based retry `attempt`:
|
|
31
|
+
* `min(baseMs * 2^(attempt-1), capMs)` plus optional additive jitter.
|
|
32
|
+
*/
|
|
33
|
+
export function computeBackoffDelayMs(attempt: number, opts: BackoffOptions): number {
|
|
34
|
+
const exp = Math.min(opts.baseMs * 2 ** (Math.max(1, attempt) - 1), opts.capMs);
|
|
35
|
+
const jitter = exp * (opts.jitterRatio ?? 0) * (opts.random ?? Math.random)();
|
|
36
|
+
return Math.floor(exp + jitter);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Transient-failure rule shared by the runtime HTTP paths: 5xx (upstream
|
|
41
|
+
* fault) and 429 (throttled) are retryable; any other status is a
|
|
42
|
+
* deterministic outcome that retrying cannot fix.
|
|
43
|
+
*/
|
|
44
|
+
export function isRetryableHttpStatus(status: number): boolean {
|
|
45
|
+
return status >= 500 || status === 429;
|
|
46
|
+
}
|
package/src/delivery-http.ts
CHANGED
|
@@ -26,10 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* shapes are structurally compatible.
|
|
29
|
+
* Resolver config consumed by `resolveHttpDelivery`
|
|
30
|
+
* (`@appstrate/afps-runtime/resolvers`). This zero-dep package is the single
|
|
31
|
+
* source of truth for the shape; afps-runtime re-exports it.
|
|
33
32
|
*/
|
|
34
33
|
export interface HttpDeliveryConfig {
|
|
35
34
|
headerName?: string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright 2025-2026 Appstrate
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical AFPS file-field predicate — the SINGLE source of truth shared by
|
|
6
|
+
* `@appstrate/core/form` (apps/web SchemaForm, apps/api) and
|
|
7
|
+
* `@appstrate/afps-runtime`'s platform-prompt composer.
|
|
8
|
+
*
|
|
9
|
+
* AFPS file fields are JSON Schema string nodes carrying `format: "uri"` plus a
|
|
10
|
+
* `contentMediaType` (single file), or an array whose `items` are such nodes
|
|
11
|
+
* (multiple files) — NEVER `type: "file"` (AFPS §3.4). The rule deliberately
|
|
12
|
+
* does NOT require `type === "string"` on the single-field branch: that
|
|
13
|
+
* preserves the historical observable behaviour of `@appstrate/core/form`'s
|
|
14
|
+
* `isFileField` (its widest consumer set), and AFPS file fields are strings
|
|
15
|
+
* anyway so the looser check is sound.
|
|
16
|
+
*
|
|
17
|
+
* Accepts a permissive `unknown` input narrowed internally so both the
|
|
18
|
+
* JSONSchema7-typed core call site and the `unknown`-typed runtime call site
|
|
19
|
+
* compile against one definition.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A single file field: `format: "uri"` + a `contentMediaType`. */
|
|
23
|
+
function isSingleFileNode(node: Record<string, unknown>): boolean {
|
|
24
|
+
return node.format === "uri" && node.contentMediaType != null && node.contentMediaType !== false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a node's `items` schema, handling the JSON Schema boolean / tuple
|
|
29
|
+
* forms (`items: false` → none; `items: [first, …]` → first object entry).
|
|
30
|
+
*/
|
|
31
|
+
function resolveItems(node: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
32
|
+
const items = node.items;
|
|
33
|
+
if (!items || typeof items === "boolean") return undefined;
|
|
34
|
+
if (Array.isArray(items)) {
|
|
35
|
+
const first = items[0];
|
|
36
|
+
return first && typeof first === "object" ? (first as Record<string, unknown>) : undefined;
|
|
37
|
+
}
|
|
38
|
+
if (typeof items === "object") return items as Record<string, unknown>;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveType(node: Record<string, unknown>): string | undefined {
|
|
43
|
+
if (typeof node.type === "string") return node.type;
|
|
44
|
+
if (Array.isArray(node.type) && node.type.length > 0 && typeof node.type[0] === "string") {
|
|
45
|
+
return node.type[0];
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detect an AFPS file field: a single string-URI node with `contentMediaType`,
|
|
52
|
+
* OR an array whose items are such a node.
|
|
53
|
+
*/
|
|
54
|
+
export function isFileField(schema: unknown): boolean {
|
|
55
|
+
if (!schema || typeof schema !== "object") return false;
|
|
56
|
+
const node = schema as Record<string, unknown>;
|
|
57
|
+
if (isSingleFileNode(node)) return true;
|
|
58
|
+
if (resolveType(node) === "array") {
|
|
59
|
+
const items = resolveItems(node);
|
|
60
|
+
if (items && isSingleFileNode(items)) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Copyright 2025-2026 Appstrate
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `guardedFetch` — the single outbound-request primitive for any path whose
|
|
6
|
+
* host comes from a less-trusted input (manifest URLs, OAuth endpoints,
|
|
7
|
+
* webhook targets, configurable model/proxy base URLs, MCP discovery).
|
|
8
|
+
*
|
|
9
|
+
* Two SSRF guards historically coexisted in this codebase: the literal
|
|
10
|
+
* `isBlockedUrl` (string-only, no DNS) and the DNS-rebind-safe
|
|
11
|
+
* `resolveAndCheckHost`. Several outbound surfaces got only the literal guard —
|
|
12
|
+
* or none — so a public hostname whose A record points at 169.254.169.254 (or
|
|
13
|
+
* a `302` to such a name) sailed through. This helper closes that class:
|
|
14
|
+
*
|
|
15
|
+
* - Follows redirects MANUALLY (`redirect: "manual"`) so every hop is checked.
|
|
16
|
+
* - Runs `resolveAndCheckHost` on the initial host AND on every redirect target
|
|
17
|
+
* (per-hop DNS resolution + blocklist), failing closed.
|
|
18
|
+
* - Rejects non-http(s) schemes and strips userinfo/fragment from redirect
|
|
19
|
+
* targets (defeats `https://user:pass@…` credential-leak + fragment tricks).
|
|
20
|
+
*
|
|
21
|
+
* Residual: like the platform's other `fetch`-delegating guards, the OS
|
|
22
|
+
* re-resolves the name when `fetch` actually connects, leaving a documented
|
|
23
|
+
* narrow TOCTOU window. Consumers that own the socket (sidecar egress
|
|
24
|
+
* listeners) pin `pinnedAddress` to fully close it; this primitive is for the
|
|
25
|
+
* many `fetch`-based callers that cannot, and is strictly stronger than the
|
|
26
|
+
* literal-only or unguarded status quo it replaces.
|
|
27
|
+
*
|
|
28
|
+
* Lives in the leaf `@appstrate/afps-shared` (re-exported by
|
|
29
|
+
* `@appstrate/core/ssrf`) so the platform, sidecar, connect and the standalone
|
|
30
|
+
* `afps` runtime can all share ONE implementation.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { resolveAndCheckHost, type HostResolver } from "./ssrf-dns.ts";
|
|
34
|
+
|
|
35
|
+
export interface GuardedFetchOptions {
|
|
36
|
+
/** Max redirect hops to follow before giving up. Default 5. */
|
|
37
|
+
maxRedirects?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Deadline in ms covering the redirect chain up to the final response's
|
|
40
|
+
* HEADERS, applied when the caller passes no `init.signal`. A hostile host
|
|
41
|
+
* must not be able to hold a hop open indefinitely (slowloris) just because
|
|
42
|
+
* a caller forgot a timeout, so the safe default lives in the primitive.
|
|
43
|
+
* Consuming the returned body is NOT covered — the timer is detached once
|
|
44
|
+
* the response is returned, so slow-but-healthy body reads are never
|
|
45
|
+
* aborted. Default 30_000. Set to 0 to disable.
|
|
46
|
+
*/
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
/** Injectable resolver for tests. Production omits it. */
|
|
49
|
+
resolve?: HostResolver;
|
|
50
|
+
/**
|
|
51
|
+
* Injectable `fetch` for tests / callers that already own a transport seam
|
|
52
|
+
* (e.g. `login-engine`'s `ctx.fetchImpl`). Production omits it and the global
|
|
53
|
+
* `fetch` is used. Routing an injected fetch THROUGH this primitive keeps the
|
|
54
|
+
* per-hop DNS guard, cross-origin credential/body stripping and scheme checks
|
|
55
|
+
* that a bare `fetchImpl` call would lose.
|
|
56
|
+
*/
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
/**
|
|
59
|
+
* Opt-in predicate for hosts the OPERATOR has explicitly trusted (e.g. an
|
|
60
|
+
* internal IdP on a private address via `OAUTH_ALLOWED_INTERNAL_IDP_HOSTS`).
|
|
61
|
+
* When it returns true the host blocklist is skipped for that hop, but the
|
|
62
|
+
* manual-redirect discipline (cross-origin body/credential stripping) still
|
|
63
|
+
* applies — so a trusted host that open-redirects cannot forward the secret.
|
|
64
|
+
*/
|
|
65
|
+
allowHost?: (host: string) => boolean;
|
|
66
|
+
/** Structured logger for blocked/hop events. Values are never secrets. */
|
|
67
|
+
logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
71
|
+
|
|
72
|
+
export class SsrfBlockedError extends Error {
|
|
73
|
+
readonly reason: string;
|
|
74
|
+
readonly host: string;
|
|
75
|
+
constructor(host: string, reason: string) {
|
|
76
|
+
super(`SSRF guard blocked outbound request to host "${host}" (${reason})`);
|
|
77
|
+
this.name = "SsrfBlockedError";
|
|
78
|
+
this.host = host;
|
|
79
|
+
this.reason = reason;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertHttp(url: URL): void {
|
|
84
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
85
|
+
throw new SsrfBlockedError(url.hostname || url.protocol, "non-http-scheme");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stripUserInfoAndFragment(url: URL): URL {
|
|
90
|
+
const clean = new URL(url.toString());
|
|
91
|
+
clean.username = "";
|
|
92
|
+
clean.password = "";
|
|
93
|
+
clean.hash = "";
|
|
94
|
+
return clean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function checkHost(url: URL, opts?: GuardedFetchOptions): Promise<void> {
|
|
98
|
+
if (opts?.allowHost?.(url.hostname)) return; // operator-trusted host — skip blocklist
|
|
99
|
+
const check = await resolveAndCheckHost(url.hostname, { resolve: opts?.resolve });
|
|
100
|
+
if (check.blocked) {
|
|
101
|
+
opts?.logger?.warn("guardedFetch blocked host", {
|
|
102
|
+
host: url.hostname,
|
|
103
|
+
reason: check.reason,
|
|
104
|
+
});
|
|
105
|
+
throw new SsrfBlockedError(url.hostname, check.reason);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* SSRF-guarded `fetch` with per-hop DNS re-checking. Signature-compatible with
|
|
111
|
+
* `fetch` for the common `(url, init)` call shape. Manual redirect handling
|
|
112
|
+
* means any `init.redirect` is ignored (always treated as "manual" internally);
|
|
113
|
+
* the returned `Response` is the first non-3xx response.
|
|
114
|
+
*/
|
|
115
|
+
export async function guardedFetch(
|
|
116
|
+
input: string | URL,
|
|
117
|
+
init?: RequestInit,
|
|
118
|
+
opts?: GuardedFetchOptions,
|
|
119
|
+
): Promise<Response> {
|
|
120
|
+
const maxRedirects = opts?.maxRedirects ?? 5;
|
|
121
|
+
|
|
122
|
+
let current = stripUserInfoAndFragment(new URL(typeof input === "string" ? input : input.href));
|
|
123
|
+
assertHttp(current);
|
|
124
|
+
await checkHost(current, opts);
|
|
125
|
+
|
|
126
|
+
// Apply a default deadline when the caller supplied no signal of its own, so
|
|
127
|
+
// a single hostile hop cannot hang forever. A caller-provided signal takes
|
|
128
|
+
// precedence (it already encodes the caller's own timeout policy). The timer
|
|
129
|
+
// is cleared once the final response's HEADERS have arrived — it must not
|
|
130
|
+
// stay attached to the returned body stream, or a caller reading a slow but
|
|
131
|
+
// healthy body past the deadline gets aborted mid-transfer.
|
|
132
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
133
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
let signal = init?.signal ?? undefined;
|
|
135
|
+
if (!signal && timeoutMs > 0) {
|
|
136
|
+
const deadline = new AbortController();
|
|
137
|
+
deadlineTimer = setTimeout(
|
|
138
|
+
() =>
|
|
139
|
+
deadline.abort(
|
|
140
|
+
new DOMException(`guardedFetch deadline of ${timeoutMs}ms exceeded`, "TimeoutError"),
|
|
141
|
+
),
|
|
142
|
+
timeoutMs,
|
|
143
|
+
);
|
|
144
|
+
signal = deadline.signal;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let method = (init?.method ?? "GET").toUpperCase();
|
|
148
|
+
let body = init?.body;
|
|
149
|
+
// Mutable header set for the chain. On a CROSS-ORIGIN redirect we drop the
|
|
150
|
+
// credential headers (browser behaviour) so a `302 → other-host` cannot
|
|
151
|
+
// forward the caller's `Authorization`/`Cookie` to a different origin — even
|
|
152
|
+
// when that origin is a legitimate public host the SSRF host-check allows.
|
|
153
|
+
const headers = new Headers(init?.headers ?? {});
|
|
154
|
+
|
|
155
|
+
// Drop the request body and the headers that describe it — used both for
|
|
156
|
+
// the standard 303/301/302 → GET rewrite and for the cross-host secret
|
|
157
|
+
// containment below, so the two sites cannot drift (a body-less request
|
|
158
|
+
// carrying a stale Content-Type/Content-Length confuses strict upstreams).
|
|
159
|
+
const dropBody = () => {
|
|
160
|
+
body = undefined;
|
|
161
|
+
for (const h of ["content-type", "content-length", "content-encoding"]) headers.delete(h);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
166
|
+
const doFetch = opts?.fetchImpl ?? fetch;
|
|
167
|
+
const res = await doFetch(current, {
|
|
168
|
+
...init,
|
|
169
|
+
method,
|
|
170
|
+
body,
|
|
171
|
+
headers,
|
|
172
|
+
signal,
|
|
173
|
+
redirect: "manual",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// `fetch` reports opaqueredirect / 3xx: follow manually so each hop is guarded.
|
|
177
|
+
const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has("location");
|
|
178
|
+
if (!isRedirect) return res;
|
|
179
|
+
|
|
180
|
+
if (hop === maxRedirects) {
|
|
181
|
+
throw new SsrfBlockedError(current.hostname, "too-many-redirects");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const location = res.headers.get("location")!;
|
|
185
|
+
const next = stripUserInfoAndFragment(new URL(location, current));
|
|
186
|
+
assertHttp(next);
|
|
187
|
+
await checkHost(next, opts);
|
|
188
|
+
|
|
189
|
+
if (next.origin !== current.origin) {
|
|
190
|
+
for (const h of ["authorization", "cookie", "proxy-authorization"]) headers.delete(h);
|
|
191
|
+
// A 307/308 preserves method+body by spec, but re-sending a
|
|
192
|
+
// secret-bearing request body (OAuth `client_secret`/`refresh_token`,
|
|
193
|
+
// a signed webhook payload) to a DIFFERENT HOST is the same
|
|
194
|
+
// credential-leak class as forwarding the `Authorization` header —
|
|
195
|
+
// and header-stripping alone does not cover it. The boundary is the
|
|
196
|
+
// HOST, not the origin: a same-host scheme/port upgrade (http→https
|
|
197
|
+
// behind a TLS-terminating proxy — routine for allowlisted internal
|
|
198
|
+
// IdPs) keeps the body, matching browser 307/308 behaviour; the one
|
|
199
|
+
// same-host case still dropped is an https→http DOWNGRADE, which
|
|
200
|
+
// would re-send the secret in cleartext.
|
|
201
|
+
const schemeDowngrade = current.protocol === "https:" && next.protocol === "http:";
|
|
202
|
+
if (body !== undefined && (next.hostname !== current.hostname || schemeDowngrade)) {
|
|
203
|
+
opts?.logger?.warn("guardedFetch dropped request body on cross-host redirect", {
|
|
204
|
+
status: res.status,
|
|
205
|
+
fromHost: current.hostname,
|
|
206
|
+
toHost: next.hostname,
|
|
207
|
+
downgrade: schemeDowngrade,
|
|
208
|
+
});
|
|
209
|
+
dropBody();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Standard redirect method/body rewriting: 303 (and 301/302 for POST per
|
|
214
|
+
// browser convention) → GET with no body; 307/308 preserve method + body
|
|
215
|
+
// (already dropped above when crossing a host boundary).
|
|
216
|
+
if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "HEAD")) {
|
|
217
|
+
method = method === "HEAD" ? "HEAD" : "GET";
|
|
218
|
+
dropBody();
|
|
219
|
+
}
|
|
220
|
+
current = next;
|
|
221
|
+
// Drain the redirect response body so the connection can be reused.
|
|
222
|
+
await res.body?.cancel().catch(() => {});
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Unreachable — loop either returns or throws.
|
|
229
|
+
throw new SsrfBlockedError(current.hostname, "redirect-loop");
|
|
230
|
+
}
|
package/src/ssrf-dns.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Copyright 2025-2026 Appstrate
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DNS-resolving layer over the literal SSRF blocklist (`./ssrf`).
|
|
6
|
+
*
|
|
7
|
+
* `isBlockedHost` alone is literal-only: a DNS name whose A/AAAA record
|
|
8
|
+
* points at an internal address (10.x, 169.254.169.254, …) passes it, and
|
|
9
|
+
* a consumer that re-resolves the name at connect time is open to a
|
|
10
|
+
* DNS-rebind bypass. Consumers that control the raw connection (sidecar
|
|
11
|
+
* egress listeners) close that gap fully by connecting to the returned
|
|
12
|
+
* `pinnedAddress`; consumers that delegate the connection to `fetch`
|
|
13
|
+
* (platform CIMD guard, MITM upstream, credential proxy, CLI api_call
|
|
14
|
+
* engine) use it as fail-closed defence-in-depth with a documented
|
|
15
|
+
* residual TOCTOU.
|
|
16
|
+
*
|
|
17
|
+
* Kept in its own subpath (not `./ssrf`) so the literal module stays free
|
|
18
|
+
* of node builtins — this module needs `node:dns` + `node:net` and is
|
|
19
|
+
* server-side only. Re-exported verbatim by `@appstrate/core/ssrf` and
|
|
20
|
+
* consumed directly by `@appstrate/afps-runtime` (which cannot depend on
|
|
21
|
+
* core — it ships standalone with the `afps` CLI).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { isIP } from "node:net";
|
|
25
|
+
import { lookup } from "node:dns/promises";
|
|
26
|
+
import { isBlockedHost } from "./ssrf.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a hostname to its IP addresses. Injectable so tests can exercise
|
|
30
|
+
* the resolution branch deterministically without real DNS. Production uses
|
|
31
|
+
* `node:dns/promises` `lookup` (honours the system resolver + `/etc/hosts`).
|
|
32
|
+
*/
|
|
33
|
+
export type HostResolver = (hostname: string) => Promise<string[]>;
|
|
34
|
+
|
|
35
|
+
export const defaultHostResolver: HostResolver = async (hostname) => {
|
|
36
|
+
const records = await lookup(hostname, { all: true });
|
|
37
|
+
return records.map((r) => r.address);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ResolvedHostCheck =
|
|
41
|
+
| { blocked: false; pinnedAddress: string }
|
|
42
|
+
| {
|
|
43
|
+
blocked: true;
|
|
44
|
+
reason: "blocked-literal" | "blocked-resolved" | "resolution-failed";
|
|
45
|
+
/** Human-readable detail for logs (resolution error message). Never a secret. */
|
|
46
|
+
detail?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* DNS-rebind-safe host check. Never throws; fails closed.
|
|
51
|
+
*
|
|
52
|
+
* - IP literals: checked against the literal blocklist, returned as their own
|
|
53
|
+
* `pinnedAddress` (no DNS round-trip).
|
|
54
|
+
* - DNS names: literal blocklist first (known-internal names), then EVERY
|
|
55
|
+
* resolved A/AAAA record is checked — if ANY lands in a blocked range, or
|
|
56
|
+
* resolution fails / returns nothing, the host is refused.
|
|
57
|
+
* - On success, `pinnedAddress` is one resolved address (IPv4 preferred) the
|
|
58
|
+
* caller MUST connect to directly — connecting by name would re-resolve and
|
|
59
|
+
* reopen the rebind window.
|
|
60
|
+
*
|
|
61
|
+
* `deps.resolve` injects a resolver for tests; `deps.isBlockedHostFn` lets
|
|
62
|
+
* callers that already take an injectable blocklist predicate thread it
|
|
63
|
+
* through. Production callers pass neither.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveAndCheckHost(
|
|
66
|
+
host: string,
|
|
67
|
+
deps?: { resolve?: HostResolver; isBlockedHostFn?: typeof isBlockedHost },
|
|
68
|
+
): Promise<ResolvedHostCheck> {
|
|
69
|
+
const isBlockedHostFn = deps?.isBlockedHostFn ?? isBlockedHost;
|
|
70
|
+
// `URL.hostname` / CONNECT targets may carry IPv6 brackets — strip for
|
|
71
|
+
// uniform handling (`isBlockedHost` normalizes internally either way).
|
|
72
|
+
const bare = host.replace(/^\[|\]$/g, "");
|
|
73
|
+
|
|
74
|
+
// Literal floor — IP literals and known-internal hostnames.
|
|
75
|
+
if (isBlockedHostFn(bare)) return { blocked: true, reason: "blocked-literal" };
|
|
76
|
+
|
|
77
|
+
// IP literal: nothing to resolve — pin the literal itself.
|
|
78
|
+
if (isIP(bare) !== 0) return { blocked: false, pinnedAddress: bare };
|
|
79
|
+
|
|
80
|
+
const resolve = deps?.resolve ?? defaultHostResolver;
|
|
81
|
+
let addresses: string[];
|
|
82
|
+
try {
|
|
83
|
+
addresses = await resolve(bare);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return {
|
|
86
|
+
blocked: true,
|
|
87
|
+
reason: "resolution-failed",
|
|
88
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (addresses.length === 0) {
|
|
92
|
+
// No address → nothing legitimate to reach (fail closed).
|
|
93
|
+
return { blocked: true, reason: "resolution-failed", detail: "no addresses resolved" };
|
|
94
|
+
}
|
|
95
|
+
if (addresses.some((addr) => isBlockedHostFn(addr))) {
|
|
96
|
+
return { blocked: true, reason: "blocked-resolved" };
|
|
97
|
+
}
|
|
98
|
+
// Prefer an IPv4 answer for the pin — pinning a AAAA record on a host
|
|
99
|
+
// without IPv6 egress would regress connectivity that name-based connects
|
|
100
|
+
// (which try both families) used to have.
|
|
101
|
+
const pinnedAddress = addresses.find((addr) => isIP(addr) === 4) ?? addresses[0]!;
|
|
102
|
+
return { blocked: false, pinnedAddress };
|
|
103
|
+
}
|
package/src/ssrf.ts
CHANGED
|
@@ -53,12 +53,20 @@ export function isBlockedHost(hostname: string): boolean {
|
|
|
53
53
|
if (ipv4Match) {
|
|
54
54
|
const a = parseInt(ipv4Match[1]!, 10);
|
|
55
55
|
const b = parseInt(ipv4Match[2]!, 10);
|
|
56
|
+
const c = parseInt(ipv4Match[3]!, 10);
|
|
56
57
|
if (a === 0) return true; // 0.0.0.0/8
|
|
57
58
|
if (a === 10) return true; // 10.0.0.0/8
|
|
58
59
|
if (a === 127) return true; // 127.0.0.0/8 (full loopback range)
|
|
59
60
|
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
60
61
|
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
61
62
|
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
|
|
63
|
+
// 100.64.0.0/10 — RFC 6598 shared/CGN space. Alibaba & Tencent Cloud expose
|
|
64
|
+
// instance metadata at 100.100.100.200, and K8s/CGN route internal traffic
|
|
65
|
+
// here; without this the whole cloud-metadata SSRF class stays open.
|
|
66
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
67
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 (benchmark)
|
|
68
|
+
if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 (IETF protocol assignments)
|
|
69
|
+
if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + 255.255.255.255
|
|
62
70
|
return false;
|
|
63
71
|
}
|
|
64
72
|
|
|
@@ -88,13 +96,37 @@ export function isBlockedHost(hostname: string): boolean {
|
|
|
88
96
|
if (mappedDot) {
|
|
89
97
|
return isBlockedHost(mappedDot[1]!);
|
|
90
98
|
}
|
|
99
|
+
|
|
100
|
+
// IPv4-compatible IPv6 (deprecated but still routed by some stacks): the
|
|
101
|
+
// low 32 bits embed an IPv4 with NO `::ffff:` prefix — ::7f00:1 = 127.0.0.1,
|
|
102
|
+
// ::a9fe:a9fe = 169.254.169.254. Without this branch these slip past the
|
|
103
|
+
// IPv4 blocklist entirely. (`::ffff:H:L` mapped form is matched above and
|
|
104
|
+
// won't collide — it carries three hextets, not two.)
|
|
105
|
+
const compatHex = h.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
106
|
+
if (compatHex) {
|
|
107
|
+
const high = parseInt(compatHex[1]!, 16);
|
|
108
|
+
const low = parseInt(compatHex[2]!, 16);
|
|
109
|
+
const ipv4 = `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
|
|
110
|
+
return isBlockedHost(ipv4);
|
|
111
|
+
}
|
|
112
|
+
const compatDot = h.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
|
|
113
|
+
if (compatDot) {
|
|
114
|
+
return isBlockedHost(compatDot[1]!);
|
|
115
|
+
}
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
return false;
|
|
94
119
|
}
|
|
95
120
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Block requests to private/internal networks. Prevents SSRF to cloud
|
|
123
|
+
* metadata, localhost, etc. `allowHost` (optional) exempts an
|
|
124
|
+
* operator-trusted hostname from the HOST blocklist only — malformed URLs
|
|
125
|
+
* and non-http(s) schemes stay fail-closed regardless, so every
|
|
126
|
+
* allowlist-aware consumer (platform egress sites, sidecar gates) shares
|
|
127
|
+
* this one parse/scheme/blocklist body instead of re-implementing it.
|
|
128
|
+
*/
|
|
129
|
+
export function isBlockedUrl(url: string, allowHost?: (host: string) => boolean): boolean {
|
|
98
130
|
let parsed: URL;
|
|
99
131
|
try {
|
|
100
132
|
parsed = new URL(url);
|
|
@@ -106,5 +138,6 @@ export function isBlockedUrl(url: string): boolean {
|
|
|
106
138
|
return true;
|
|
107
139
|
}
|
|
108
140
|
|
|
141
|
+
if (allowHost?.(parsed.hostname)) return false;
|
|
109
142
|
return isBlockedHost(parsed.hostname);
|
|
110
143
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Copyright 2025-2026 Appstrate
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Memory-bounded ZIP decompression — the single primitive for ingesting
|
|
6
|
+
* untrusted archives (AFPS bundles, package ZIPs, integration bundles).
|
|
7
|
+
*
|
|
8
|
+
* The prior pattern everywhere was `unzipSync(archive)` (fflate, fully
|
|
9
|
+
* synchronous) followed by a `sumSizes(...) > maxDecompressedBytes` check.
|
|
10
|
+
* That check runs AFTER the entire archive is already materialized in memory,
|
|
11
|
+
* so the guard can never prevent the OOM it claims to: a 1 MB archive can
|
|
12
|
+
* inflate to gigabytes before the cap is ever evaluated, and a single crafted
|
|
13
|
+
* member is enough.
|
|
14
|
+
*
|
|
15
|
+
* This helper feeds the compressed bytes to fflate's streaming `Unzip` in
|
|
16
|
+
* fixed-size slices and enforces a CUMULATIVE decompressed budget inside each
|
|
17
|
+
* per-file `ondata` chunk callback — so it aborts mid-inflate the instant the
|
|
18
|
+
* running total crosses the limit, never allocating materially more than the
|
|
19
|
+
* budget. It counts ACTUAL decompressed bytes (not the archive's declared
|
|
20
|
+
* sizes, which a bomb forges), so it is robust against lying headers.
|
|
21
|
+
*
|
|
22
|
+
* Lives in leaf `@appstrate/afps-shared` (re-exported by `@appstrate/core/zip`)
|
|
23
|
+
* so the platform, sidecar, and the standalone `afps` runtime share ONE
|
|
24
|
+
* bounded implementation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Unzip, UnzipInflate } from "fflate";
|
|
28
|
+
|
|
29
|
+
export type DecompressionLimitReason =
|
|
30
|
+
| "decompressed-budget-exceeded"
|
|
31
|
+
| "file-too-large"
|
|
32
|
+
| "too-many-files"
|
|
33
|
+
| "corrupt-archive";
|
|
34
|
+
|
|
35
|
+
export class DecompressionLimitError extends Error {
|
|
36
|
+
readonly reason: DecompressionLimitReason;
|
|
37
|
+
constructor(reason: DecompressionLimitReason, detail?: string) {
|
|
38
|
+
super(`ZIP decompression refused: ${reason}${detail ? ` (${detail})` : ""}`);
|
|
39
|
+
this.name = "DecompressionLimitError";
|
|
40
|
+
this.reason = reason;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BoundedUnzipLimits {
|
|
45
|
+
/** Hard cap on the sum of all decompressed bytes. Aborts mid-inflate. */
|
|
46
|
+
maxDecompressedBytes: number;
|
|
47
|
+
/** Optional per-file decompressed cap. */
|
|
48
|
+
maxFileBytes?: number;
|
|
49
|
+
/** Optional cap on the number of entries. */
|
|
50
|
+
maxFiles?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SLICE = 64 * 1024;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Streaming, memory-bounded unzip. Returns a `name → bytes` record of the
|
|
57
|
+
* archive's file entries (directory entries excluded). Throws
|
|
58
|
+
* {@link DecompressionLimitError} the moment any budget is crossed — before the
|
|
59
|
+
* offending bytes accumulate — or on a corrupt/unsupported archive.
|
|
60
|
+
*
|
|
61
|
+
* Path sanitization is intentionally NOT done here (callers apply their own
|
|
62
|
+
* entry-name policy on the returned names); this primitive owns only the
|
|
63
|
+
* resource-exhaustion boundary.
|
|
64
|
+
*/
|
|
65
|
+
export function unzipBounded(
|
|
66
|
+
artifact: Uint8Array,
|
|
67
|
+
limits: BoundedUnzipLimits,
|
|
68
|
+
): Record<string, Uint8Array> {
|
|
69
|
+
const { maxDecompressedBytes, maxFileBytes, maxFiles } = limits;
|
|
70
|
+
|
|
71
|
+
// Streaming `Unzip` scans for local-file-header signatures and silently
|
|
72
|
+
// yields nothing on a buffer that isn't a ZIP — so guard the archive magic
|
|
73
|
+
// up front. Every ZIP begins with "PK" (0x50 0x4b): a local file header
|
|
74
|
+
// (PK\x03\x04) or, for an empty archive, the end-of-central-directory
|
|
75
|
+
// (PK\x05\x06). Anything else is corrupt/non-ZIP and must fail loudly.
|
|
76
|
+
if (artifact.length < 4 || artifact[0] !== 0x50 || artifact[1] !== 0x4b) {
|
|
77
|
+
throw new DecompressionLimitError("corrupt-archive", "not a ZIP archive");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const out: Record<string, Uint8Array> = {};
|
|
81
|
+
let total = 0;
|
|
82
|
+
let fileCount = 0;
|
|
83
|
+
let caught: unknown = null;
|
|
84
|
+
|
|
85
|
+
const unzipper = new Unzip((file) => {
|
|
86
|
+
fileCount += 1;
|
|
87
|
+
if (maxFiles !== undefined && fileCount > maxFiles) {
|
|
88
|
+
throw new DecompressionLimitError("too-many-files", `> ${maxFiles}`);
|
|
89
|
+
}
|
|
90
|
+
// Directory entries carry no data — skip; fflate still requires start().
|
|
91
|
+
const isDir = file.name.endsWith("/");
|
|
92
|
+
const chunks: Uint8Array[] = [];
|
|
93
|
+
let fileSize = 0;
|
|
94
|
+
file.ondata = (err, chunk, final) => {
|
|
95
|
+
// Once a limit/corruption verdict is set, fflate may still invoke this
|
|
96
|
+
// callback again within the same push (as it unwinds the inflate) — never
|
|
97
|
+
// overwrite the first verdict; just re-throw it so the reason is stable.
|
|
98
|
+
if (caught) throw caught;
|
|
99
|
+
if (err) {
|
|
100
|
+
caught = new DecompressionLimitError("corrupt-archive", err.message);
|
|
101
|
+
throw caught;
|
|
102
|
+
}
|
|
103
|
+
fileSize += chunk.length;
|
|
104
|
+
total += chunk.length;
|
|
105
|
+
if (maxFileBytes !== undefined && fileSize > maxFileBytes) {
|
|
106
|
+
caught = new DecompressionLimitError("file-too-large", file.name);
|
|
107
|
+
throw caught;
|
|
108
|
+
}
|
|
109
|
+
if (total > maxDecompressedBytes) {
|
|
110
|
+
caught = new DecompressionLimitError("decompressed-budget-exceeded");
|
|
111
|
+
throw caught;
|
|
112
|
+
}
|
|
113
|
+
if (!isDir) chunks.push(chunk);
|
|
114
|
+
if (final && !isDir) {
|
|
115
|
+
out[file.name] = concatChunks(chunks, fileSize);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
file.start();
|
|
119
|
+
});
|
|
120
|
+
unzipper.register(UnzipInflate);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
for (let off = 0; off < artifact.length; off += SLICE) {
|
|
124
|
+
const end = Math.min(off + SLICE, artifact.length);
|
|
125
|
+
unzipper.push(artifact.subarray(off, end), end === artifact.length);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof DecompressionLimitError) throw err;
|
|
129
|
+
if (caught instanceof DecompressionLimitError) throw caught;
|
|
130
|
+
throw new DecompressionLimitError(
|
|
131
|
+
"corrupt-archive",
|
|
132
|
+
err instanceof Error ? err.message : String(err),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function concatChunks(chunks: Uint8Array[], size: number): Uint8Array {
|
|
140
|
+
if (chunks.length === 1) return chunks[0]!;
|
|
141
|
+
const merged = new Uint8Array(size);
|
|
142
|
+
let pos = 0;
|
|
143
|
+
for (const c of chunks) {
|
|
144
|
+
merged.set(c, pos);
|
|
145
|
+
pos += c.length;
|
|
146
|
+
}
|
|
147
|
+
return merged;
|
|
148
|
+
}
|