@appstrate/afps-shared 0.1.0 → 0.3.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 +11 -3
- package/src/api-tool-naming.ts +76 -0
- 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 +359 -0
- package/src/mcp-naming.ts +58 -0
- package/src/ssrf-dns.ts +104 -0
- package/src/ssrf.ts +35 -2
- package/src/unzip-bounded.ts +145 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,19 @@
|
|
|
40
40
|
"./integrity": "./src/integrity.ts",
|
|
41
41
|
"./credential-template": "./src/credential-template.ts",
|
|
42
42
|
"./delivery-http": "./src/delivery-http.ts",
|
|
43
|
+
"./api-tool-naming": "./src/api-tool-naming.ts",
|
|
44
|
+
"./mcp-naming": "./src/mcp-naming.ts",
|
|
45
|
+
"./file-field": "./src/file-field.ts",
|
|
43
46
|
"./ssrf": "./src/ssrf.ts",
|
|
44
|
-
"./token-usage": "./src/token-usage.ts"
|
|
47
|
+
"./token-usage": "./src/token-usage.ts",
|
|
48
|
+
"./ssrf-dns": "./src/ssrf-dns.ts",
|
|
49
|
+
"./guarded-fetch": "./src/guarded-fetch.ts",
|
|
50
|
+
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
51
|
+
"./backoff": "./src/backoff.ts"
|
|
45
52
|
},
|
|
46
53
|
"dependencies": {
|
|
47
|
-
"
|
|
54
|
+
"fflate": "^0.8.3",
|
|
55
|
+
"semver": "^7.8.4"
|
|
48
56
|
},
|
|
49
57
|
"devDependencies": {
|
|
50
58
|
"@types/semver": "^7.7.1"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Appstrate
|
|
3
|
+
|
|
4
|
+
import { MCP_TOOL_NAME_MAX_LENGTH, MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH } from "./mcp-naming.ts";
|
|
5
|
+
|
|
6
|
+
/** Canonical unprefixed name of the credential-injecting API tool. */
|
|
7
|
+
export const API_CALL_TOOL_NAME = "api_call";
|
|
8
|
+
|
|
9
|
+
/** Canonical unprefixed name of the resumable-upload companion. */
|
|
10
|
+
export const API_UPLOAD_TOOL_NAME = "api_upload";
|
|
11
|
+
|
|
12
|
+
// McpHost can append `_999` (four characters) to a colliding namespace. The
|
|
13
|
+
// longest synthetic prefix is `api_upload__` (12 characters), so an auth token
|
|
14
|
+
// gets at most 56 - 24 - 2 - 12 = 18 characters in every valid allocation.
|
|
15
|
+
const MCP_NAMESPACE_COLLISION_SUFFIX_MAX_LENGTH = 4;
|
|
16
|
+
const MCP_NAMESPACE_SEPARATOR_LENGTH = 2;
|
|
17
|
+
const API_UPLOAD_AUTH_PREFIX_LENGTH = `${API_UPLOAD_TOOL_NAME}__`.length;
|
|
18
|
+
const API_TOOL_AUTH_TOKEN_LENGTH =
|
|
19
|
+
MCP_TOOL_NAME_MAX_LENGTH -
|
|
20
|
+
(MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH + MCP_NAMESPACE_COLLISION_SUFFIX_MAX_LENGTH) -
|
|
21
|
+
MCP_NAMESPACE_SEPARATOR_LENGTH -
|
|
22
|
+
API_UPLOAD_AUTH_PREFIX_LENGTH;
|
|
23
|
+
const API_TOOL_RAW_AUTH_KEY_MAX_LENGTH = API_TOOL_AUTH_TOKEN_LENGTH - 1;
|
|
24
|
+
const API_TOOL_AUTH_HASH_HEX_LENGTH = API_TOOL_AUTH_TOKEN_LENGTH - 2;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map an AFPS auth key onto the bounded token used in multi-auth tool names.
|
|
28
|
+
*
|
|
29
|
+
* Keys up to 17 characters remain verbatim. Longer keys become `h0` followed
|
|
30
|
+
* by a 64-bit FNV-1a digest, producing exactly 18 characters. The two output
|
|
31
|
+
* domains are disjoint by length, so a short raw key can never impersonate a
|
|
32
|
+
* compacted long key. Consumers also reject duplicate tokens inside one
|
|
33
|
+
* integration, making the bounded alias fail closed even under a deliberate
|
|
34
|
+
* hash collision. The full auth key still travels separately in runtime
|
|
35
|
+
* metadata and is never recovered by parsing this display/routing token.
|
|
36
|
+
*/
|
|
37
|
+
export function apiToolAuthToken(authKey: string): string {
|
|
38
|
+
if (authKey.length <= API_TOOL_RAW_AUTH_KEY_MAX_LENGTH) return authKey;
|
|
39
|
+
return `h0${fnv1a64(authKey).slice(0, API_TOOL_AUTH_HASH_HEX_LENGTH)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Throw when two distinct auth keys collapse onto the same bounded token. */
|
|
43
|
+
export function assertUniqueApiToolAuthTokens(authKeys: readonly string[]): void {
|
|
44
|
+
const owners = new Map<string, string>();
|
|
45
|
+
for (const authKey of authKeys) {
|
|
46
|
+
const token = apiToolAuthToken(authKey);
|
|
47
|
+
const existing = owners.get(token);
|
|
48
|
+
if (existing !== undefined && existing !== authKey) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`api tool auth-token collision between ${JSON.stringify(existing)} and ${JSON.stringify(authKey)}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
owners.set(token, authKey);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fnv1a64(value: string): string {
|
|
58
|
+
let hash = 0xcbf29ce484222325n;
|
|
59
|
+
const prime = 0x100000001b3n;
|
|
60
|
+
const mask = 0xffffffffffffffffn;
|
|
61
|
+
for (const byte of new TextEncoder().encode(value)) {
|
|
62
|
+
hash ^= BigInt(byte);
|
|
63
|
+
hash = (hash * prime) & mask;
|
|
64
|
+
}
|
|
65
|
+
return hash.toString(16).padStart(16, "0");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Derive the unprefixed api_call name for one auth surface. */
|
|
69
|
+
export function apiCallToolNameForAuth(authKey: string, multiAuth: boolean): string {
|
|
70
|
+
return multiAuth ? `${API_CALL_TOOL_NAME}__${apiToolAuthToken(authKey)}` : API_CALL_TOOL_NAME;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Derive the api_upload companion while preserving the auth-scoped token. */
|
|
74
|
+
export function apiUploadToolNameFor(apiCallToolName: string): string {
|
|
75
|
+
return `${API_UPLOAD_TOOL_NAME}${apiCallToolName.slice(API_CALL_TOOL_NAME.length)}`;
|
|
76
|
+
}
|
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,359 @@
|
|
|
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
|
+
* credential-proxy targets).
|
|
9
|
+
*
|
|
10
|
+
* Two SSRF guards historically coexisted in this codebase: the literal
|
|
11
|
+
* `isBlockedUrl` (string-only, no DNS) and the DNS-rebind-safe
|
|
12
|
+
* `resolveAndCheckHost`. Several outbound surfaces got only the literal guard —
|
|
13
|
+
* or none — so a public hostname whose A record points at 169.254.169.254 (or
|
|
14
|
+
* a `302` to such a name) sailed through. This helper closes that class:
|
|
15
|
+
*
|
|
16
|
+
* - Follows redirects MANUALLY (`redirect: "manual"`) so every hop is checked.
|
|
17
|
+
* - Runs `resolveAndCheckHost` on the initial host AND on every redirect target
|
|
18
|
+
* (per-hop DNS resolution + blocklist), failing closed.
|
|
19
|
+
* - Enforces the CALLER'S reachability contract on every hop when
|
|
20
|
+
* `validateHop` is provided: the predicate runs on hop 0 and on every
|
|
21
|
+
* redirect target BEFORE any request is sent to it, and a throw ABORTS the
|
|
22
|
+
* whole exchange (an off-contract hop is treated as the attack, never
|
|
23
|
+
* silently stripped-and-followed). This is how allowlist-scoped callers
|
|
24
|
+
* (credential proxy `authorized_uris`) extend their allowlist to the full
|
|
25
|
+
* redirect chain instead of only the initial URL.
|
|
26
|
+
* - Strips credential headers on any cross-origin hop: the builtin
|
|
27
|
+
* `authorization`/`cookie`/`proxy-authorization` set UNIONED with the
|
|
28
|
+
* caller's `sensitiveHeaders` (vendor-specific names like `X-Api-Key` that
|
|
29
|
+
* the primitive cannot know about).
|
|
30
|
+
* - Rejects non-http(s) schemes and strips userinfo/fragment from redirect
|
|
31
|
+
* targets (defeats `https://user:pass@…` credential-leak + fragment tricks).
|
|
32
|
+
* - CONNECTS TO THE VALIDATED ADDRESS: under Bun with the global `fetch`, each
|
|
33
|
+
* hop's request goes to the `pinnedAddress` returned by the guard (URL host
|
|
34
|
+
* rewritten to the resolved IP) while the logical `Host` header and the TLS
|
|
35
|
+
* SNI + certificate identity (`tls.serverName`) are preserved. The OS never
|
|
36
|
+
* re-resolves the name at connect time, so the classic check-then-fetch
|
|
37
|
+
* DNS-rebind TOCTOU is closed, not merely narrowed.
|
|
38
|
+
*
|
|
39
|
+
* The address pin falls back to a name-based connect (re-opening the
|
|
40
|
+
* documented, narrow re-resolve TOCTOU — still guarded per hop) in exactly
|
|
41
|
+
* these cases:
|
|
42
|
+
* - a caller-injected `fetchImpl` (the seam owns its own transport; Bun's
|
|
43
|
+
* `tls`/URL-rewrite contract cannot be assumed),
|
|
44
|
+
* - a non-Bun runtime (no `fetch` `tls.serverName` extension to preserve SNI —
|
|
45
|
+
* pinning without it would break certificate validation),
|
|
46
|
+
* - an operator-trusted host via `allowHost` (blocklist and resolution are
|
|
47
|
+
* skipped by design, there is nothing to pin),
|
|
48
|
+
* - an IP-literal URL (already its own pin; nothing to rebind).
|
|
49
|
+
*
|
|
50
|
+
* Lives in the leaf `@appstrate/afps-shared` (re-exported by
|
|
51
|
+
* `@appstrate/core/ssrf`) so the platform, sidecar, connect and the standalone
|
|
52
|
+
* `afps` runtime can all share ONE implementation.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { resolveAndCheckHost, type HostResolver } from "./ssrf-dns.ts";
|
|
56
|
+
|
|
57
|
+
export interface GuardedFetchOptions {
|
|
58
|
+
/** Max redirect hops to follow before giving up. Default 5. */
|
|
59
|
+
maxRedirects?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Deadline in ms covering the redirect chain up to the final response's
|
|
62
|
+
* HEADERS, applied when the caller passes no `init.signal`. A hostile host
|
|
63
|
+
* must not be able to hold a hop open indefinitely (slowloris) just because
|
|
64
|
+
* a caller forgot a timeout, so the safe default lives in the primitive.
|
|
65
|
+
* Consuming the returned body is NOT covered — the timer is detached once
|
|
66
|
+
* the response is returned, so slow-but-healthy body reads are never
|
|
67
|
+
* aborted. Default 30_000. Set to 0 to disable.
|
|
68
|
+
*/
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
/** Injectable resolver for tests. Production omits it. */
|
|
71
|
+
resolve?: HostResolver;
|
|
72
|
+
/**
|
|
73
|
+
* Injectable `fetch` for tests / callers that already own a transport seam
|
|
74
|
+
* (e.g. `login-engine`'s `ctx.fetchImpl`). Production omits it and the global
|
|
75
|
+
* `fetch` is used. Routing an injected fetch THROUGH this primitive keeps the
|
|
76
|
+
* per-hop DNS guard, cross-origin credential/body stripping and scheme checks
|
|
77
|
+
* that a bare `fetchImpl` call would lose. NOTE: an injected fetch disables
|
|
78
|
+
* the address pin (see module doc) — the seam owns the connection.
|
|
79
|
+
*/
|
|
80
|
+
fetchImpl?: typeof fetch;
|
|
81
|
+
/**
|
|
82
|
+
* Opt-in predicate for hosts the OPERATOR has explicitly trusted (e.g. an
|
|
83
|
+
* internal IdP on a private address via `OAUTH_ALLOWED_INTERNAL_IDP_HOSTS`).
|
|
84
|
+
* When it returns true the host blocklist is skipped for that hop, but the
|
|
85
|
+
* manual-redirect discipline (cross-origin body/credential stripping) still
|
|
86
|
+
* applies — so a trusted host that open-redirects cannot forward the secret.
|
|
87
|
+
*/
|
|
88
|
+
allowHost?: (host: string) => boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Set to `false` to disable connecting to the DNS-validated address and
|
|
91
|
+
* connect by name instead (per-hop guard still runs). Default: pin whenever
|
|
92
|
+
* the runtime supports it. The only known reason to disable is an egress
|
|
93
|
+
* HTTP proxy whose ACLs match on hostname rather than IP.
|
|
94
|
+
*/
|
|
95
|
+
pinToResolvedAddress?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Caller-owned per-hop reachability contract. Called for EVERY hop —
|
|
98
|
+
* INCLUDING hop 0 — with the logical (userinfo/fragment-stripped) URL,
|
|
99
|
+
* BEFORE any request is sent to that URL and before DNS resolution.
|
|
100
|
+
* Throw to abort the entire exchange: an off-contract redirect target must
|
|
101
|
+
* kill the request, not be followed with stripped credentials — for
|
|
102
|
+
* allowlist-scoped callers the allowlist IS the security boundary and a
|
|
103
|
+
* hop that leaves it is the attack. The thrown error propagates to the
|
|
104
|
+
* caller unwrapped, so callers keep their own error taxonomy and redaction
|
|
105
|
+
* (do NOT embed secrets in the message — the hop URL itself may carry an
|
|
106
|
+
* interpolated credential; redact before throwing).
|
|
107
|
+
*/
|
|
108
|
+
validateHop?: (url: URL, hop: number) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Additional header names (case-insensitive) treated as credentials for
|
|
111
|
+
* cross-origin redirect stripping. UNIONED with the builtin
|
|
112
|
+
* `authorization`/`cookie`/`proxy-authorization` set — never a
|
|
113
|
+
* replacement. Callers that inject vendor-specific credential headers
|
|
114
|
+
* (`X-Api-Key`, `X-Auth-Token`, …) MUST list them here, or a cross-origin
|
|
115
|
+
* redirect would carry them to the new origin.
|
|
116
|
+
*/
|
|
117
|
+
sensitiveHeaders?: readonly string[];
|
|
118
|
+
/** Structured logger for blocked/hop events. Values are never secrets. */
|
|
119
|
+
logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Bun extends `fetch` with a per-request `tls` option (`serverName`,
|
|
126
|
+
* `checkServerIdentity`, …). The address pin depends on `tls.serverName` to
|
|
127
|
+
* keep SNI + certificate identity on the logical hostname while the TCP
|
|
128
|
+
* connection goes to the pinned IP — without it, pinning an https URL would
|
|
129
|
+
* fail certificate validation, so on other runtimes we fall back to a
|
|
130
|
+
* name-based connect. Verified against Bun 1.3.x: `tls.serverName` drives
|
|
131
|
+
* both the emitted SNI and the identity check (a mismatching serverName
|
|
132
|
+
* fails with ERR_TLS_CERT_ALTNAME_INVALID).
|
|
133
|
+
*/
|
|
134
|
+
const runtimeSupportsFetchTls = (globalThis as { Bun?: unknown }).Bun !== undefined;
|
|
135
|
+
|
|
136
|
+
export class SsrfBlockedError extends Error {
|
|
137
|
+
readonly reason: string;
|
|
138
|
+
readonly host: string;
|
|
139
|
+
constructor(host: string, reason: string) {
|
|
140
|
+
super(`SSRF guard blocked outbound request to host "${host}" (${reason})`);
|
|
141
|
+
this.name = "SsrfBlockedError";
|
|
142
|
+
this.host = host;
|
|
143
|
+
this.reason = reason;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertHttp(url: URL): void {
|
|
148
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
149
|
+
throw new SsrfBlockedError(url.hostname || url.protocol, "non-http-scheme");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stripUserInfoAndFragment(url: URL): URL {
|
|
154
|
+
const clean = new URL(url.toString());
|
|
155
|
+
clean.username = "";
|
|
156
|
+
clean.password = "";
|
|
157
|
+
clean.hash = "";
|
|
158
|
+
return clean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run the per-hop host guard. Returns the address the hop MUST connect to,
|
|
163
|
+
* or `undefined` when there is nothing to pin (operator-trusted host).
|
|
164
|
+
* Throws {@link SsrfBlockedError} on a blocked host (fail closed).
|
|
165
|
+
*/
|
|
166
|
+
async function checkHost(url: URL, opts?: GuardedFetchOptions): Promise<string | undefined> {
|
|
167
|
+
if (opts?.allowHost?.(url.hostname)) return undefined; // operator-trusted host — skip blocklist
|
|
168
|
+
const check = await resolveAndCheckHost(url.hostname, { resolve: opts?.resolve });
|
|
169
|
+
if (check.blocked) {
|
|
170
|
+
opts?.logger?.warn("guardedFetch blocked host", {
|
|
171
|
+
host: url.hostname,
|
|
172
|
+
reason: check.reason,
|
|
173
|
+
});
|
|
174
|
+
throw new SsrfBlockedError(url.hostname, check.reason);
|
|
175
|
+
}
|
|
176
|
+
return check.pinnedAddress;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* SSRF-guarded `fetch` with per-hop DNS re-checking and (under Bun) a real
|
|
181
|
+
* connection pin to the validated address. Signature-compatible with `fetch`
|
|
182
|
+
* for the common `(url, init)` call shape. Manual redirect handling means any
|
|
183
|
+
* `init.redirect` is ignored (always treated as "manual" internally); the
|
|
184
|
+
* returned `Response` is the first non-3xx response.
|
|
185
|
+
*/
|
|
186
|
+
export async function guardedFetch(
|
|
187
|
+
input: string | URL,
|
|
188
|
+
init?: RequestInit,
|
|
189
|
+
opts?: GuardedFetchOptions,
|
|
190
|
+
): Promise<Response> {
|
|
191
|
+
const maxRedirects = opts?.maxRedirects ?? 5;
|
|
192
|
+
|
|
193
|
+
let current = stripUserInfoAndFragment(new URL(typeof input === "string" ? input : input.href));
|
|
194
|
+
assertHttp(current);
|
|
195
|
+
// Hop 0 runs the caller's reachability contract too — the initial URL is
|
|
196
|
+
// just the first hop of the chain, not a privileged one.
|
|
197
|
+
opts?.validateHop?.(current, 0);
|
|
198
|
+
let pinnedAddress = await checkHost(current, opts);
|
|
199
|
+
|
|
200
|
+
// Apply a default deadline when the caller supplied no signal of its own, so
|
|
201
|
+
// a single hostile hop cannot hang forever. A caller-provided signal takes
|
|
202
|
+
// precedence (it already encodes the caller's own timeout policy). The timer
|
|
203
|
+
// is cleared once the final response's HEADERS have arrived — it must not
|
|
204
|
+
// stay attached to the returned body stream, or a caller reading a slow but
|
|
205
|
+
// healthy body past the deadline gets aborted mid-transfer.
|
|
206
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
207
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
208
|
+
let signal = init?.signal ?? undefined;
|
|
209
|
+
if (!signal && timeoutMs > 0) {
|
|
210
|
+
const deadline = new AbortController();
|
|
211
|
+
deadlineTimer = setTimeout(
|
|
212
|
+
() =>
|
|
213
|
+
deadline.abort(
|
|
214
|
+
new DOMException(`guardedFetch deadline of ${timeoutMs}ms exceeded`, "TimeoutError"),
|
|
215
|
+
),
|
|
216
|
+
timeoutMs,
|
|
217
|
+
);
|
|
218
|
+
signal = deadline.signal;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let method = (init?.method ?? "GET").toUpperCase();
|
|
222
|
+
let body = init?.body;
|
|
223
|
+
// Mutable header set for the chain. On a CROSS-ORIGIN redirect we drop the
|
|
224
|
+
// credential headers (browser behaviour) so a `302 → other-host` cannot
|
|
225
|
+
// forward the caller's `Authorization`/`Cookie` to a different origin — even
|
|
226
|
+
// when that origin is a legitimate public host the SSRF host-check allows.
|
|
227
|
+
// The strip set is the builtin trio UNIONED with the caller-declared
|
|
228
|
+
// `sensitiveHeaders` (vendor-specific credential names the primitive cannot
|
|
229
|
+
// know, e.g. an injected `X-Api-Key`).
|
|
230
|
+
const headers = new Headers(init?.headers ?? {});
|
|
231
|
+
const sensitiveHeaderNames = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
232
|
+
for (const h of opts?.sensitiveHeaders ?? []) sensitiveHeaderNames.add(h.toLowerCase());
|
|
233
|
+
// A caller-supplied Host header is honoured only on the first, unpinned hop
|
|
234
|
+
// (a virtual-host override for the URL the caller chose). On every later or
|
|
235
|
+
// pinned hop the logical URL owns the Host value.
|
|
236
|
+
const callerSetHost = headers.has("host");
|
|
237
|
+
|
|
238
|
+
// The address pin requires owning the socket semantics: Bun's `fetch` `tls`
|
|
239
|
+
// extension AND the global fetch (an injected transport seam cannot be
|
|
240
|
+
// assumed to honour either the URL rewrite or the tls option).
|
|
241
|
+
const pinningEnabled =
|
|
242
|
+
runtimeSupportsFetchTls && !opts?.fetchImpl && opts?.pinToResolvedAddress !== false;
|
|
243
|
+
const callerTls = (init as { tls?: Record<string, unknown> } | undefined)?.tls;
|
|
244
|
+
|
|
245
|
+
// Drop the request body and the headers that describe it — used both for
|
|
246
|
+
// the standard 303/301/302 → GET rewrite and for the cross-host secret
|
|
247
|
+
// containment below, so the two sites cannot drift (a body-less request
|
|
248
|
+
// carrying a stale Content-Type/Content-Length confuses strict upstreams).
|
|
249
|
+
const dropBody = () => {
|
|
250
|
+
body = undefined;
|
|
251
|
+
for (const h of ["content-type", "content-length", "content-encoding"]) headers.delete(h);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
256
|
+
// Pin the hop: connect to the validated address, keep the logical
|
|
257
|
+
// hostname on the wire (`Host` header) and in the TLS handshake
|
|
258
|
+
// (`tls.serverName` → SNI + certificate identity). `current` stays the
|
|
259
|
+
// LOGICAL URL — redirect resolution and origin comparisons never see
|
|
260
|
+
// the pinned form.
|
|
261
|
+
const bareHost = current.hostname.replace(/^\[|\]$/g, "");
|
|
262
|
+
const pin = pinnedAddress;
|
|
263
|
+
const applyPin = pinningEnabled && pin !== undefined && pin !== bareHost;
|
|
264
|
+
let requestUrl = current;
|
|
265
|
+
let tlsOverride: Record<string, unknown> | undefined;
|
|
266
|
+
if (applyPin) {
|
|
267
|
+
requestUrl = new URL(current.toString());
|
|
268
|
+
requestUrl.hostname = pin.includes(":") ? `[${pin}]` : pin;
|
|
269
|
+
headers.set("host", current.host);
|
|
270
|
+
if (current.protocol === "https:") {
|
|
271
|
+
tlsOverride = { ...(callerTls ?? {}), serverName: current.hostname };
|
|
272
|
+
}
|
|
273
|
+
} else if (!(hop === 0 && callerSetHost)) {
|
|
274
|
+
// Unpinned hop: let the runtime derive Host from the URL — a Host
|
|
275
|
+
// value pinned for a previous hop must not leak onto this one.
|
|
276
|
+
headers.delete("host");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const doFetch = opts?.fetchImpl ?? fetch;
|
|
280
|
+
const res = await doFetch(requestUrl, {
|
|
281
|
+
...init,
|
|
282
|
+
method,
|
|
283
|
+
body,
|
|
284
|
+
headers,
|
|
285
|
+
signal,
|
|
286
|
+
redirect: "manual",
|
|
287
|
+
...(tlsOverride ? { tls: tlsOverride } : {}),
|
|
288
|
+
} as RequestInit);
|
|
289
|
+
|
|
290
|
+
// `fetch` reports opaqueredirect / 3xx: follow manually so each hop is guarded.
|
|
291
|
+
const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has("location");
|
|
292
|
+
if (!isRedirect) return res;
|
|
293
|
+
|
|
294
|
+
if (hop === maxRedirects) {
|
|
295
|
+
throw new SsrfBlockedError(current.hostname, "too-many-redirects");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const location = res.headers.get("location")!;
|
|
299
|
+
const next = stripUserInfoAndFragment(new URL(location, current));
|
|
300
|
+
assertHttp(next);
|
|
301
|
+
// Caller's reachability contract FIRST (cheap, sync, fail-closed): an
|
|
302
|
+
// off-contract hop aborts the exchange before we even resolve it. A
|
|
303
|
+
// same-origin redirect can walk off an allowlisted PATH while keeping
|
|
304
|
+
// every header and the body — only the caller's predicate can see that.
|
|
305
|
+
opts?.validateHop?.(next, hop + 1);
|
|
306
|
+
const nextPin = await checkHost(next, opts);
|
|
307
|
+
|
|
308
|
+
if (next.origin !== current.origin) {
|
|
309
|
+
for (const h of sensitiveHeaderNames) headers.delete(h);
|
|
310
|
+
// A 307/308 preserves method+body by spec, but re-sending a
|
|
311
|
+
// secret-bearing request body (OAuth `client_secret`/`refresh_token`,
|
|
312
|
+
// a signed webhook payload) to a DIFFERENT HOST is the same
|
|
313
|
+
// credential-leak class as forwarding the `Authorization` header —
|
|
314
|
+
// and header-stripping alone does not cover it. The boundary is the
|
|
315
|
+
// HOST, not the origin: a same-host scheme/port upgrade (http→https
|
|
316
|
+
// behind a TLS-terminating proxy — routine for allowlisted internal
|
|
317
|
+
// IdPs) keeps the body, matching browser 307/308 behaviour; the one
|
|
318
|
+
// same-host case still dropped is an https→http DOWNGRADE, which
|
|
319
|
+
// would re-send the secret in cleartext.
|
|
320
|
+
//
|
|
321
|
+
// When the caller declared a `validateHop` contract the request is a
|
|
322
|
+
// credential-bearing exchange by definition (that is why the caller
|
|
323
|
+
// scoped it), so belt-and-braces: ANY origin change drops the body,
|
|
324
|
+
// including the same-host scheme/port cases kept above.
|
|
325
|
+
const schemeDowngrade = current.protocol === "https:" && next.protocol === "http:";
|
|
326
|
+
const hasHopContract = opts?.validateHop !== undefined;
|
|
327
|
+
if (
|
|
328
|
+
body !== undefined &&
|
|
329
|
+
(next.hostname !== current.hostname || schemeDowngrade || hasHopContract)
|
|
330
|
+
) {
|
|
331
|
+
opts?.logger?.warn("guardedFetch dropped request body on cross-host redirect", {
|
|
332
|
+
status: res.status,
|
|
333
|
+
fromHost: current.hostname,
|
|
334
|
+
toHost: next.hostname,
|
|
335
|
+
downgrade: schemeDowngrade,
|
|
336
|
+
});
|
|
337
|
+
dropBody();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Standard redirect method/body rewriting: 303 (and 301/302 for POST per
|
|
342
|
+
// browser convention) → GET with no body; 307/308 preserve method + body
|
|
343
|
+
// (already dropped above when crossing a host boundary).
|
|
344
|
+
if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "HEAD")) {
|
|
345
|
+
method = method === "HEAD" ? "HEAD" : "GET";
|
|
346
|
+
dropBody();
|
|
347
|
+
}
|
|
348
|
+
current = next;
|
|
349
|
+
pinnedAddress = nextPin;
|
|
350
|
+
// Drain the redirect response body so the connection can be reused.
|
|
351
|
+
await res.body?.cancel().catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Unreachable — loop either returns or throws.
|
|
358
|
+
throw new SsrfBlockedError(current.hostname, "redirect-loop");
|
|
359
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Appstrate
|
|
3
|
+
|
|
4
|
+
/** Appstrate's MCP tool-name ceiling, including the namespace. */
|
|
5
|
+
export const MCP_TOOL_NAME_MAX_LENGTH = 56;
|
|
6
|
+
|
|
7
|
+
/** Maximum namespace length before McpHost adds an optional `_2`…`_999`. */
|
|
8
|
+
export const MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH = 20;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical namespace normalisation shared by McpHost and the portable AFPS
|
|
12
|
+
* runtime. Package ids such as `@appstrate/google-drive` become a lowercase
|
|
13
|
+
* snake-case namespace capped before collision suffixing.
|
|
14
|
+
*/
|
|
15
|
+
export function normaliseMcpToolNamespace(raw: string): string {
|
|
16
|
+
if (typeof raw !== "string") return "";
|
|
17
|
+
const out = trimUnderscores(
|
|
18
|
+
raw
|
|
19
|
+
.replace(/^@/, "")
|
|
20
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
21
|
+
.toLowerCase(),
|
|
22
|
+
);
|
|
23
|
+
return out.slice(0, MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonicalise an untrusted upstream tool body before adding our namespace.
|
|
28
|
+
* An upstream namespace is stripped so `drive__api-call` becomes `api_call`,
|
|
29
|
+
* matching McpHost's outward naming contract.
|
|
30
|
+
*/
|
|
31
|
+
export function normaliseMcpToolBody(raw: string): string {
|
|
32
|
+
if (typeof raw !== "string") return "";
|
|
33
|
+
let out = trimUnderscores(raw.replace(/[^a-zA-Z0-9_]+/g, "_").toLowerCase());
|
|
34
|
+
const separator = out.indexOf("__");
|
|
35
|
+
if (separator >= 0 && separator < out.length - 2) {
|
|
36
|
+
out = out.slice(separator + 2);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Trim underscore runs in linear time without a backtracking expression. */
|
|
42
|
+
function trimUnderscores(value: string): string {
|
|
43
|
+
let start = 0;
|
|
44
|
+
while (start < value.length && value.charCodeAt(start) === 95) start += 1;
|
|
45
|
+
let end = value.length;
|
|
46
|
+
while (end > start && value.charCodeAt(end - 1) === 95) end -= 1;
|
|
47
|
+
return value.slice(start, end);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Allocate the same `_2`…`_999` namespace suffix used by McpHost. */
|
|
51
|
+
export function allocateMcpToolNamespace(base: string, used: ReadonlySet<string>): string {
|
|
52
|
+
if (!used.has(base)) return base;
|
|
53
|
+
for (let suffix = 2; suffix < 1000; suffix += 1) {
|
|
54
|
+
const candidate = `${base}_${suffix}`;
|
|
55
|
+
if (!used.has(candidate)) return candidate;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`exhausted MCP namespace suffixes for ${JSON.stringify(base)}`);
|
|
58
|
+
}
|
package/src/ssrf-dns.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
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 connection close that gap
|
|
11
|
+
* fully by connecting to the returned `pinnedAddress`: sidecar egress
|
|
12
|
+
* listeners own the raw socket, and `guardedFetch` (./guarded-fetch.ts)
|
|
13
|
+
* rewrites the request URL to the pin while preserving Host + TLS SNI
|
|
14
|
+
* (Bun). A consumer that instead delegates the connection to a plain
|
|
15
|
+
* name-based `fetch` only gets fail-closed defence-in-depth with a
|
|
16
|
+
* residual re-resolve TOCTOU — prefer `guardedFetch`.
|
|
17
|
+
*
|
|
18
|
+
* Kept in its own subpath (not `./ssrf`) so the literal module stays free
|
|
19
|
+
* of node builtins — this module needs `node:dns` + `node:net` and is
|
|
20
|
+
* server-side only. Re-exported verbatim by `@appstrate/core/ssrf` and
|
|
21
|
+
* consumed directly by `@appstrate/afps-runtime` (which cannot depend on
|
|
22
|
+
* core — it ships standalone with the `afps` CLI).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { isIP } from "node:net";
|
|
26
|
+
import { lookup } from "node:dns/promises";
|
|
27
|
+
import { isBlockedHost } from "./ssrf.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a hostname to its IP addresses. Injectable so tests can exercise
|
|
31
|
+
* the resolution branch deterministically without real DNS. Production uses
|
|
32
|
+
* `node:dns/promises` `lookup` (honours the system resolver + `/etc/hosts`).
|
|
33
|
+
*/
|
|
34
|
+
export type HostResolver = (hostname: string) => Promise<string[]>;
|
|
35
|
+
|
|
36
|
+
export const defaultHostResolver: HostResolver = async (hostname) => {
|
|
37
|
+
const records = await lookup(hostname, { all: true });
|
|
38
|
+
return records.map((r) => r.address);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ResolvedHostCheck =
|
|
42
|
+
| { blocked: false; pinnedAddress: string }
|
|
43
|
+
| {
|
|
44
|
+
blocked: true;
|
|
45
|
+
reason: "blocked-literal" | "blocked-resolved" | "resolution-failed";
|
|
46
|
+
/** Human-readable detail for logs (resolution error message). Never a secret. */
|
|
47
|
+
detail?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* DNS-rebind-safe host check. Never throws; fails closed.
|
|
52
|
+
*
|
|
53
|
+
* - IP literals: checked against the literal blocklist, returned as their own
|
|
54
|
+
* `pinnedAddress` (no DNS round-trip).
|
|
55
|
+
* - DNS names: literal blocklist first (known-internal names), then EVERY
|
|
56
|
+
* resolved A/AAAA record is checked — if ANY lands in a blocked range, or
|
|
57
|
+
* resolution fails / returns nothing, the host is refused.
|
|
58
|
+
* - On success, `pinnedAddress` is one resolved address (IPv4 preferred) the
|
|
59
|
+
* caller MUST connect to directly — connecting by name would re-resolve and
|
|
60
|
+
* reopen the rebind window.
|
|
61
|
+
*
|
|
62
|
+
* `deps.resolve` injects a resolver for tests; `deps.isBlockedHostFn` lets
|
|
63
|
+
* callers that already take an injectable blocklist predicate thread it
|
|
64
|
+
* through. Production callers pass neither.
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveAndCheckHost(
|
|
67
|
+
host: string,
|
|
68
|
+
deps?: { resolve?: HostResolver; isBlockedHostFn?: typeof isBlockedHost },
|
|
69
|
+
): Promise<ResolvedHostCheck> {
|
|
70
|
+
const isBlockedHostFn = deps?.isBlockedHostFn ?? isBlockedHost;
|
|
71
|
+
// `URL.hostname` / CONNECT targets may carry IPv6 brackets — strip for
|
|
72
|
+
// uniform handling (`isBlockedHost` normalizes internally either way).
|
|
73
|
+
const bare = host.replace(/^\[|\]$/g, "");
|
|
74
|
+
|
|
75
|
+
// Literal floor — IP literals and known-internal hostnames.
|
|
76
|
+
if (isBlockedHostFn(bare)) return { blocked: true, reason: "blocked-literal" };
|
|
77
|
+
|
|
78
|
+
// IP literal: nothing to resolve — pin the literal itself.
|
|
79
|
+
if (isIP(bare) !== 0) return { blocked: false, pinnedAddress: bare };
|
|
80
|
+
|
|
81
|
+
const resolve = deps?.resolve ?? defaultHostResolver;
|
|
82
|
+
let addresses: string[];
|
|
83
|
+
try {
|
|
84
|
+
addresses = await resolve(bare);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return {
|
|
87
|
+
blocked: true,
|
|
88
|
+
reason: "resolution-failed",
|
|
89
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (addresses.length === 0) {
|
|
93
|
+
// No address → nothing legitimate to reach (fail closed).
|
|
94
|
+
return { blocked: true, reason: "resolution-failed", detail: "no addresses resolved" };
|
|
95
|
+
}
|
|
96
|
+
if (addresses.some((addr) => isBlockedHostFn(addr))) {
|
|
97
|
+
return { blocked: true, reason: "blocked-resolved" };
|
|
98
|
+
}
|
|
99
|
+
// Prefer an IPv4 answer for the pin — pinning a AAAA record on a host
|
|
100
|
+
// without IPv6 egress would regress connectivity that name-based connects
|
|
101
|
+
// (which try both families) used to have.
|
|
102
|
+
const pinnedAddress = addresses.find((addr) => isIP(addr) === 4) ?? addresses[0]!;
|
|
103
|
+
return { blocked: false, pinnedAddress };
|
|
104
|
+
}
|
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,145 @@
|
|
|
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" | "file-too-large" | "too-many-files" | "corrupt-archive";
|
|
31
|
+
|
|
32
|
+
export class DecompressionLimitError extends Error {
|
|
33
|
+
readonly reason: DecompressionLimitReason;
|
|
34
|
+
constructor(reason: DecompressionLimitReason, detail?: string) {
|
|
35
|
+
super(`ZIP decompression refused: ${reason}${detail ? ` (${detail})` : ""}`);
|
|
36
|
+
this.name = "DecompressionLimitError";
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BoundedUnzipLimits {
|
|
42
|
+
/** Hard cap on the sum of all decompressed bytes. Aborts mid-inflate. */
|
|
43
|
+
maxDecompressedBytes: number;
|
|
44
|
+
/** Optional per-file decompressed cap. */
|
|
45
|
+
maxFileBytes?: number;
|
|
46
|
+
/** Optional cap on the number of entries. */
|
|
47
|
+
maxFiles?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SLICE = 64 * 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Streaming, memory-bounded unzip. Returns a `name → bytes` record of the
|
|
54
|
+
* archive's file entries (directory entries excluded). Throws
|
|
55
|
+
* {@link DecompressionLimitError} the moment any budget is crossed — before the
|
|
56
|
+
* offending bytes accumulate — or on a corrupt/unsupported archive.
|
|
57
|
+
*
|
|
58
|
+
* Path sanitization is intentionally NOT done here (callers apply their own
|
|
59
|
+
* entry-name policy on the returned names); this primitive owns only the
|
|
60
|
+
* resource-exhaustion boundary.
|
|
61
|
+
*/
|
|
62
|
+
export function unzipBounded(
|
|
63
|
+
artifact: Uint8Array,
|
|
64
|
+
limits: BoundedUnzipLimits,
|
|
65
|
+
): Record<string, Uint8Array> {
|
|
66
|
+
const { maxDecompressedBytes, maxFileBytes, maxFiles } = limits;
|
|
67
|
+
|
|
68
|
+
// Streaming `Unzip` scans for local-file-header signatures and silently
|
|
69
|
+
// yields nothing on a buffer that isn't a ZIP — so guard the archive magic
|
|
70
|
+
// up front. Every ZIP begins with "PK" (0x50 0x4b): a local file header
|
|
71
|
+
// (PK\x03\x04) or, for an empty archive, the end-of-central-directory
|
|
72
|
+
// (PK\x05\x06). Anything else is corrupt/non-ZIP and must fail loudly.
|
|
73
|
+
if (artifact.length < 4 || artifact[0] !== 0x50 || artifact[1] !== 0x4b) {
|
|
74
|
+
throw new DecompressionLimitError("corrupt-archive", "not a ZIP archive");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const out: Record<string, Uint8Array> = {};
|
|
78
|
+
let total = 0;
|
|
79
|
+
let fileCount = 0;
|
|
80
|
+
let caught: unknown = null;
|
|
81
|
+
|
|
82
|
+
const unzipper = new Unzip((file) => {
|
|
83
|
+
fileCount += 1;
|
|
84
|
+
if (maxFiles !== undefined && fileCount > maxFiles) {
|
|
85
|
+
throw new DecompressionLimitError("too-many-files", `> ${maxFiles}`);
|
|
86
|
+
}
|
|
87
|
+
// Directory entries carry no data — skip; fflate still requires start().
|
|
88
|
+
const isDir = file.name.endsWith("/");
|
|
89
|
+
const chunks: Uint8Array[] = [];
|
|
90
|
+
let fileSize = 0;
|
|
91
|
+
file.ondata = (err, chunk, final) => {
|
|
92
|
+
// Once a limit/corruption verdict is set, fflate may still invoke this
|
|
93
|
+
// callback again within the same push (as it unwinds the inflate) — never
|
|
94
|
+
// overwrite the first verdict; just re-throw it so the reason is stable.
|
|
95
|
+
if (caught) throw caught;
|
|
96
|
+
if (err) {
|
|
97
|
+
caught = new DecompressionLimitError("corrupt-archive", err.message);
|
|
98
|
+
throw caught;
|
|
99
|
+
}
|
|
100
|
+
fileSize += chunk.length;
|
|
101
|
+
total += chunk.length;
|
|
102
|
+
if (maxFileBytes !== undefined && fileSize > maxFileBytes) {
|
|
103
|
+
caught = new DecompressionLimitError("file-too-large", file.name);
|
|
104
|
+
throw caught;
|
|
105
|
+
}
|
|
106
|
+
if (total > maxDecompressedBytes) {
|
|
107
|
+
caught = new DecompressionLimitError("decompressed-budget-exceeded");
|
|
108
|
+
throw caught;
|
|
109
|
+
}
|
|
110
|
+
if (!isDir) chunks.push(chunk);
|
|
111
|
+
if (final && !isDir) {
|
|
112
|
+
out[file.name] = concatChunks(chunks, fileSize);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
file.start();
|
|
116
|
+
});
|
|
117
|
+
unzipper.register(UnzipInflate);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
for (let off = 0; off < artifact.length; off += SLICE) {
|
|
121
|
+
const end = Math.min(off + SLICE, artifact.length);
|
|
122
|
+
unzipper.push(artifact.subarray(off, end), end === artifact.length);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof DecompressionLimitError) throw err;
|
|
126
|
+
if (caught instanceof DecompressionLimitError) throw caught;
|
|
127
|
+
throw new DecompressionLimitError(
|
|
128
|
+
"corrupt-archive",
|
|
129
|
+
err instanceof Error ? err.message : String(err),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function concatChunks(chunks: Uint8Array[], size: number): Uint8Array {
|
|
137
|
+
if (chunks.length === 1) return chunks[0]!;
|
|
138
|
+
const merged = new Uint8Array(size);
|
|
139
|
+
let pos = 0;
|
|
140
|
+
for (const c of chunks) {
|
|
141
|
+
merged.set(c, pos);
|
|
142
|
+
pos += c.length;
|
|
143
|
+
}
|
|
144
|
+
return merged;
|
|
145
|
+
}
|