@appstrate/afps-shared 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/api-tool-naming.ts +76 -0
- package/src/guarded-fetch.ts +149 -20
- package/src/mcp-naming.ts +58 -0
- package/src/signed-token.ts +106 -0
- package/src/ssrf-dns.ts +7 -6
- package/src/unzip-bounded.ts +1 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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,14 @@
|
|
|
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",
|
|
43
45
|
"./file-field": "./src/file-field.ts",
|
|
44
46
|
"./ssrf": "./src/ssrf.ts",
|
|
45
47
|
"./token-usage": "./src/token-usage.ts",
|
|
46
48
|
"./ssrf-dns": "./src/ssrf-dns.ts",
|
|
47
49
|
"./guarded-fetch": "./src/guarded-fetch.ts",
|
|
50
|
+
"./signed-token": "./src/signed-token.ts",
|
|
48
51
|
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
49
52
|
"./backoff": "./src/backoff.ts"
|
|
50
53
|
},
|
|
@@ -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/guarded-fetch.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* `guardedFetch` — the single outbound-request primitive for any path whose
|
|
6
6
|
* host comes from a less-trusted input (manifest URLs, OAuth endpoints,
|
|
7
|
-
* webhook targets, configurable model/proxy base URLs, MCP discovery
|
|
7
|
+
* webhook targets, configurable model/proxy base URLs, MCP discovery,
|
|
8
|
+
* credential-proxy targets).
|
|
8
9
|
*
|
|
9
10
|
* Two SSRF guards historically coexisted in this codebase: the literal
|
|
10
11
|
* `isBlockedUrl` (string-only, no DNS) and the DNS-rebind-safe
|
|
@@ -15,15 +16,36 @@
|
|
|
15
16
|
* - Follows redirects MANUALLY (`redirect: "manual"`) so every hop is checked.
|
|
16
17
|
* - Runs `resolveAndCheckHost` on the initial host AND on every redirect target
|
|
17
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).
|
|
18
30
|
* - Rejects non-http(s) schemes and strips userinfo/fragment from redirect
|
|
19
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.
|
|
20
38
|
*
|
|
21
|
-
*
|
|
22
|
-
* re-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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).
|
|
27
49
|
*
|
|
28
50
|
* Lives in the leaf `@appstrate/afps-shared` (re-exported by
|
|
29
51
|
* `@appstrate/core/ssrf`) so the platform, sidecar, connect and the standalone
|
|
@@ -52,7 +74,8 @@ export interface GuardedFetchOptions {
|
|
|
52
74
|
* (e.g. `login-engine`'s `ctx.fetchImpl`). Production omits it and the global
|
|
53
75
|
* `fetch` is used. Routing an injected fetch THROUGH this primitive keeps the
|
|
54
76
|
* per-hop DNS guard, cross-origin credential/body stripping and scheme checks
|
|
55
|
-
* that a bare `fetchImpl` call would lose.
|
|
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.
|
|
56
79
|
*/
|
|
57
80
|
fetchImpl?: typeof fetch;
|
|
58
81
|
/**
|
|
@@ -63,12 +86,53 @@ export interface GuardedFetchOptions {
|
|
|
63
86
|
* applies — so a trusted host that open-redirects cannot forward the secret.
|
|
64
87
|
*/
|
|
65
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[];
|
|
66
118
|
/** Structured logger for blocked/hop events. Values are never secrets. */
|
|
67
119
|
logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void };
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
71
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
|
+
|
|
72
136
|
export class SsrfBlockedError extends Error {
|
|
73
137
|
readonly reason: string;
|
|
74
138
|
readonly host: string;
|
|
@@ -94,8 +158,13 @@ function stripUserInfoAndFragment(url: URL): URL {
|
|
|
94
158
|
return clean;
|
|
95
159
|
}
|
|
96
160
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
99
168
|
const check = await resolveAndCheckHost(url.hostname, { resolve: opts?.resolve });
|
|
100
169
|
if (check.blocked) {
|
|
101
170
|
opts?.logger?.warn("guardedFetch blocked host", {
|
|
@@ -104,13 +173,15 @@ async function checkHost(url: URL, opts?: GuardedFetchOptions): Promise<void> {
|
|
|
104
173
|
});
|
|
105
174
|
throw new SsrfBlockedError(url.hostname, check.reason);
|
|
106
175
|
}
|
|
176
|
+
return check.pinnedAddress;
|
|
107
177
|
}
|
|
108
178
|
|
|
109
179
|
/**
|
|
110
|
-
* SSRF-guarded `fetch` with per-hop DNS re-checking
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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.
|
|
114
185
|
*/
|
|
115
186
|
export async function guardedFetch(
|
|
116
187
|
input: string | URL,
|
|
@@ -121,7 +192,10 @@ export async function guardedFetch(
|
|
|
121
192
|
|
|
122
193
|
let current = stripUserInfoAndFragment(new URL(typeof input === "string" ? input : input.href));
|
|
123
194
|
assertHttp(current);
|
|
124
|
-
|
|
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);
|
|
125
199
|
|
|
126
200
|
// Apply a default deadline when the caller supplied no signal of its own, so
|
|
127
201
|
// a single hostile hop cannot hang forever. A caller-provided signal takes
|
|
@@ -150,7 +224,23 @@ export async function guardedFetch(
|
|
|
150
224
|
// credential headers (browser behaviour) so a `302 → other-host` cannot
|
|
151
225
|
// forward the caller's `Authorization`/`Cookie` to a different origin — even
|
|
152
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`).
|
|
153
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;
|
|
154
244
|
|
|
155
245
|
// Drop the request body and the headers that describe it — used both for
|
|
156
246
|
// the standard 303/301/302 → GET rewrite and for the cross-host secret
|
|
@@ -163,15 +253,39 @@ export async function guardedFetch(
|
|
|
163
253
|
|
|
164
254
|
try {
|
|
165
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
|
+
|
|
166
279
|
const doFetch = opts?.fetchImpl ?? fetch;
|
|
167
|
-
const res = await doFetch(
|
|
280
|
+
const res = await doFetch(requestUrl, {
|
|
168
281
|
...init,
|
|
169
282
|
method,
|
|
170
283
|
body,
|
|
171
284
|
headers,
|
|
172
285
|
signal,
|
|
173
286
|
redirect: "manual",
|
|
174
|
-
|
|
287
|
+
...(tlsOverride ? { tls: tlsOverride } : {}),
|
|
288
|
+
} as RequestInit);
|
|
175
289
|
|
|
176
290
|
// `fetch` reports opaqueredirect / 3xx: follow manually so each hop is guarded.
|
|
177
291
|
const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has("location");
|
|
@@ -184,10 +298,15 @@ export async function guardedFetch(
|
|
|
184
298
|
const location = res.headers.get("location")!;
|
|
185
299
|
const next = stripUserInfoAndFragment(new URL(location, current));
|
|
186
300
|
assertHttp(next);
|
|
187
|
-
|
|
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);
|
|
188
307
|
|
|
189
308
|
if (next.origin !== current.origin) {
|
|
190
|
-
for (const h of
|
|
309
|
+
for (const h of sensitiveHeaderNames) headers.delete(h);
|
|
191
310
|
// A 307/308 preserves method+body by spec, but re-sending a
|
|
192
311
|
// secret-bearing request body (OAuth `client_secret`/`refresh_token`,
|
|
193
312
|
// a signed webhook payload) to a DIFFERENT HOST is the same
|
|
@@ -198,8 +317,17 @@ export async function guardedFetch(
|
|
|
198
317
|
// IdPs) keeps the body, matching browser 307/308 behaviour; the one
|
|
199
318
|
// same-host case still dropped is an https→http DOWNGRADE, which
|
|
200
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.
|
|
201
325
|
const schemeDowngrade = current.protocol === "https:" && next.protocol === "http:";
|
|
202
|
-
|
|
326
|
+
const hasHopContract = opts?.validateHop !== undefined;
|
|
327
|
+
if (
|
|
328
|
+
body !== undefined &&
|
|
329
|
+
(next.hostname !== current.hostname || schemeDowngrade || hasHopContract)
|
|
330
|
+
) {
|
|
203
331
|
opts?.logger?.warn("guardedFetch dropped request body on cross-host redirect", {
|
|
204
332
|
status: res.status,
|
|
205
333
|
fromHost: current.hostname,
|
|
@@ -218,6 +346,7 @@ export async function guardedFetch(
|
|
|
218
346
|
dropBody();
|
|
219
347
|
}
|
|
220
348
|
current = next;
|
|
349
|
+
pinnedAddress = nextPin;
|
|
221
350
|
// Drain the redirect response body so the connection can be reused.
|
|
222
351
|
await res.body?.cancel().catch(() => {});
|
|
223
352
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyring-HMAC capability tokens — the ONE codec behind every short-lived,
|
|
5
|
+
* URL-carried capability the platform mints (filesystem/proxy upload URLs,
|
|
6
|
+
* document previews, hosted connect sessions).
|
|
7
|
+
*
|
|
8
|
+
* Wire format: `base64url(JSON payload).base64url(HMAC-SHA256)`.
|
|
9
|
+
*
|
|
10
|
+
* Two properties are load-bearing and were previously re-implemented (and
|
|
11
|
+
* drifted) per token type:
|
|
12
|
+
*
|
|
13
|
+
* - **Keyring rotation.** A secret is a comma-separated list (or an array):
|
|
14
|
+
* the FIRST key signs new tokens, ALL keys verify, so a rotation never
|
|
15
|
+
* invalidates tokens already in flight. Individual keys must therefore not
|
|
16
|
+
* contain commas.
|
|
17
|
+
* - **Domain separation.** {@link signKeyringToken} takes the domain as its
|
|
18
|
+
* FIRST, REQUIRED argument and mixes it into the signed content, so a token
|
|
19
|
+
* minted for one purpose can never be verified as another — including when
|
|
20
|
+
* two token types share a signing secret (upload URLs and document previews
|
|
21
|
+
* both key off `UPLOAD_SIGNING_SECRET`). Making the parameter mandatory is
|
|
22
|
+
* the point: an optional domain is a domain someone forgets, and the
|
|
23
|
+
* resulting protection is one-directional — exactly the asymmetry this
|
|
24
|
+
* module replaces.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT part of the codec: expiry and claim validation. Every token
|
|
27
|
+
* type names its expiry field differently and enforces its own required
|
|
28
|
+
* claims, so {@link verifyKeyringToken} returns the decoded payload after the
|
|
29
|
+
* signature check and leaves semantics to the caller.
|
|
30
|
+
*
|
|
31
|
+
* Zero-dependency leaf so `@appstrate/core` (storage), the platform API
|
|
32
|
+
* (document previews) and `@appstrate/connect` (hosted connect sessions) can
|
|
33
|
+
* all sit above it without a cycle.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a signing secret into a keyring. A plain string is split on commas
|
|
40
|
+
* (rotation: prepend the new key); empty segments are dropped.
|
|
41
|
+
*/
|
|
42
|
+
export function toKeyring(secret: string | readonly string[]): string[] {
|
|
43
|
+
const keys = typeof secret === "string" ? secret.split(",") : [...secret];
|
|
44
|
+
return keys.filter((k) => k.length > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encode + HMAC-sign a payload with the FIRST key of the keyring, binding the
|
|
49
|
+
* signature to `domain`. Throws when the keyring holds no usable key.
|
|
50
|
+
*
|
|
51
|
+
* `domain` is a short, stable, versioned literal (`"doc-preview.v1."`) — change
|
|
52
|
+
* it and every token already in flight stops verifying.
|
|
53
|
+
*/
|
|
54
|
+
export function signKeyringToken(
|
|
55
|
+
domain: string,
|
|
56
|
+
payload: unknown,
|
|
57
|
+
secret: string | readonly string[],
|
|
58
|
+
): string {
|
|
59
|
+
const [activeKey] = toKeyring(secret);
|
|
60
|
+
if (!activeKey) throw new Error("signKeyringToken requires at least one signing key");
|
|
61
|
+
const body = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64url");
|
|
62
|
+
const sig = createHmac("sha256", activeKey)
|
|
63
|
+
.update(domain + body)
|
|
64
|
+
.digest("base64url");
|
|
65
|
+
return `${body}.${sig}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Verify a token against `domain` and decode its payload. Returns null on any
|
|
70
|
+
* failure (malformed shape, wrong/absent signature, non-JSON body) — never
|
|
71
|
+
* throws. Verifies against EVERY key of the keyring (constant-time comparison
|
|
72
|
+
* per key) so tokens signed before a rotation stay valid.
|
|
73
|
+
*
|
|
74
|
+
* The returned value is the raw decoded JSON cast to `T`: the signature proves
|
|
75
|
+
* WE minted it, not that its fields are the ones the caller expects. Callers
|
|
76
|
+
* validate expiry + required claims themselves.
|
|
77
|
+
*/
|
|
78
|
+
export function verifyKeyringToken<T>(
|
|
79
|
+
domain: string,
|
|
80
|
+
token: string,
|
|
81
|
+
secret: string | readonly string[],
|
|
82
|
+
): T | null {
|
|
83
|
+
const dot = token.indexOf(".");
|
|
84
|
+
if (dot <= 0) return null;
|
|
85
|
+
const body = token.slice(0, dot);
|
|
86
|
+
const sig = token.slice(dot + 1);
|
|
87
|
+
const a = Buffer.from(sig);
|
|
88
|
+
let valid = false;
|
|
89
|
+
for (const key of toKeyring(secret)) {
|
|
90
|
+
const b = Buffer.from(
|
|
91
|
+
createHmac("sha256", key)
|
|
92
|
+
.update(domain + body)
|
|
93
|
+
.digest("base64url"),
|
|
94
|
+
);
|
|
95
|
+
if (a.length === b.length && timingSafeEqual(a, b)) {
|
|
96
|
+
valid = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!valid) return null;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(Buffer.from(body, "base64url").toString("utf-8")) as T;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/ssrf-dns.ts
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
* `isBlockedHost` alone is literal-only: a DNS name whose A/AAAA record
|
|
8
8
|
* points at an internal address (10.x, 169.254.169.254, …) passes it, and
|
|
9
9
|
* a consumer that re-resolves the name at connect time is open to a
|
|
10
|
-
* DNS-rebind bypass. Consumers that control the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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`.
|
|
16
17
|
*
|
|
17
18
|
* Kept in its own subpath (not `./ssrf`) so the literal module stays free
|
|
18
19
|
* of node builtins — this module needs `node:dns` + `node:net` and is
|
package/src/unzip-bounded.ts
CHANGED
|
@@ -27,10 +27,7 @@
|
|
|
27
27
|
import { Unzip, UnzipInflate } from "fflate";
|
|
28
28
|
|
|
29
29
|
export type DecompressionLimitReason =
|
|
30
|
-
|
|
31
|
-
| "file-too-large"
|
|
32
|
-
| "too-many-files"
|
|
33
|
-
| "corrupt-archive";
|
|
30
|
+
"decompressed-budget-exceeded" | "file-too-large" | "too-many-files" | "corrupt-archive";
|
|
34
31
|
|
|
35
32
|
export class DecompressionLimitError extends Error {
|
|
36
33
|
readonly reason: DecompressionLimitReason;
|