@stigmer/runner 3.18.0 → 3.19.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/dist/.build-fingerprint +1 -1
- package/dist/shared/mcp-oauth-detect.d.ts +1 -31
- package/dist/shared/mcp-oauth-detect.js +22 -49
- package/dist/shared/mcp-oauth-detect.js.map +1 -1
- package/dist/tools/url-guard.d.ts +10 -5
- package/dist/tools/url-guard.js +31 -137
- package/dist/tools/url-guard.js.map +1 -1
- package/package.json +5 -4
- package/src/shared/__tests__/mcp-oauth-detect.test.ts +20 -0
- package/src/shared/mcp-oauth-detect.ts +24 -56
- package/src/tools/url-guard.ts +41 -148
package/dist/.build-fingerprint
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"hash":"
|
|
1
|
+
{"hash":"6742d02a5e9d864d","builtAt":"2026-09-19T18:45:10.207Z","fileCount":329}
|
|
@@ -1,23 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* OAuth-challenge classification for HTTP MCP endpoints.
|
|
3
|
-
*
|
|
4
|
-
* When an HTTP MCP endpoint requires OAuth (per the MCP Authorization spec /
|
|
5
|
-
* RFC 9728), an unauthenticated or statically-tokened request is answered with
|
|
6
|
-
* `401` and a `WWW-Authenticate: Bearer ...` header pointing at OAuth protected
|
|
7
|
-
* resource metadata. The MCP client SDK surfaces this only as an opaque
|
|
8
|
-
* aggregate error ("unhandled errors in a TaskGroup"), which tells the user
|
|
9
|
-
* nothing.
|
|
10
|
-
*
|
|
11
|
-
* This module turns that opaque failure into a precise, actionable signal: it
|
|
12
|
-
* re-probes the endpoint once (only on the failure path, so the happy path pays
|
|
13
|
-
* nothing) and, if it sees an OAuth challenge, returns an {@link
|
|
14
|
-
* OAuthRequiredError} whose message tells the user to connect via OAuth instead
|
|
15
|
-
* of a manual token.
|
|
16
|
-
*
|
|
17
|
-
* Kept transport-library-agnostic (uses global `fetch`) so it is reusable by
|
|
18
|
-
* connect-time discovery, execution-time error enrichment, and any other caller
|
|
19
|
-
* that holds an MCP endpoint URL + headers.
|
|
20
|
-
*/
|
|
1
|
+
export { isOAuthChallenge, parseResourceMetadataUrl } from "@stigmer/outbound/mcp-oauth";
|
|
21
2
|
/**
|
|
22
3
|
* Raised when an HTTP MCP endpoint answers with an OAuth authentication
|
|
23
4
|
* challenge. The message is self-contained and user-facing: it survives the
|
|
@@ -31,17 +12,6 @@ export declare class OAuthRequiredError extends Error {
|
|
|
31
12
|
readonly resourceMetadataUrl?: string | undefined;
|
|
32
13
|
constructor(serverSlug: string, resourceMetadataUrl?: string | undefined);
|
|
33
14
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Whether a `WWW-Authenticate` header value is an OAuth challenge.
|
|
36
|
-
*
|
|
37
|
-
* The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
|
|
38
|
-
* require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
|
|
39
|
-
* pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
|
|
40
|
-
* an OAuth requirement) is not misclassified.
|
|
41
|
-
*/
|
|
42
|
-
export declare function isOAuthChallenge(wwwAuthenticate: string): boolean;
|
|
43
|
-
/** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
|
|
44
|
-
export declare function parseResourceMetadataUrl(wwwAuthenticate: string): string | undefined;
|
|
45
15
|
/**
|
|
46
16
|
* Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
|
|
47
17
|
* challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
|
|
@@ -14,11 +14,23 @@
|
|
|
14
14
|
* OAuthRequiredError} whose message tells the user to connect via OAuth instead
|
|
15
15
|
* of a manual token.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* The rule and the request have one home, `@stigmer/outbound/mcp-oauth`,
|
|
18
|
+
* shared with the control plane (which asks the same question of a URL-only
|
|
19
|
+
* server at save time, so the Sign in button exists before the first run)
|
|
20
|
+
* and the catalogue audit. The request is the complete `initialize`
|
|
21
|
+
* (protocol version, capabilities, client info): several hosted servers
|
|
22
|
+
* validate the request before they check authentication and answer a bare
|
|
23
|
+
* one with HTTP 200 and a JSON-RPC error, so the bare request this module
|
|
24
|
+
* once sent never reached their 401 (stigmer #1188). What stays here is the
|
|
25
|
+
* runner's own: its deadline, its name in the handshake, and the sentence
|
|
26
|
+
* the user reads.
|
|
20
27
|
*/
|
|
28
|
+
import { probeEndpointAuth } from "@stigmer/outbound/mcp-oauth";
|
|
29
|
+
export { isOAuthChallenge, parseResourceMetadataUrl } from "@stigmer/outbound/mcp-oauth";
|
|
30
|
+
/** The failure path can afford a patient probe; a save cannot, and uses its own. */
|
|
21
31
|
const OAUTH_PROBE_TIMEOUT_MS = 10_000;
|
|
32
|
+
/** How the runner names itself in the handshake's `clientInfo`. */
|
|
33
|
+
const PROBE_CLIENT_NAME = "stigmer-runner";
|
|
22
34
|
/**
|
|
23
35
|
* Raised when an HTTP MCP endpoint answers with an OAuth authentication
|
|
24
36
|
* challenge. The message is self-contained and user-facing: it survives the
|
|
@@ -39,25 +51,6 @@ export class OAuthRequiredError extends Error {
|
|
|
39
51
|
this.name = "OAuthRequiredError";
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
|
-
/**
|
|
43
|
-
* Whether a `WWW-Authenticate` header value is an OAuth challenge.
|
|
44
|
-
*
|
|
45
|
-
* The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
|
|
46
|
-
* require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
|
|
47
|
-
* pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
|
|
48
|
-
* an OAuth requirement) is not misclassified.
|
|
49
|
-
*/
|
|
50
|
-
export function isOAuthChallenge(wwwAuthenticate) {
|
|
51
|
-
const value = wwwAuthenticate.toLowerCase();
|
|
52
|
-
if (!value.includes("bearer"))
|
|
53
|
-
return false;
|
|
54
|
-
return value.includes("oauth") || value.includes("resource_metadata");
|
|
55
|
-
}
|
|
56
|
-
/** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
|
|
57
|
-
export function parseResourceMetadataUrl(wwwAuthenticate) {
|
|
58
|
-
const match = /resource_metadata="([^"]+)"/i.exec(wwwAuthenticate);
|
|
59
|
-
return match?.[1];
|
|
60
|
-
}
|
|
61
54
|
/**
|
|
62
55
|
* Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
|
|
63
56
|
* challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
|
|
@@ -67,33 +60,13 @@ export function parseResourceMetadataUrl(wwwAuthenticate) {
|
|
|
67
60
|
* step can never mask or replace the original discovery error.
|
|
68
61
|
*/
|
|
69
62
|
export async function detectOAuthChallenge(url, headers, slug) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
...(headers ?? {}),
|
|
77
|
-
},
|
|
78
|
-
// A minimal MCP initialize request — enough to trigger the endpoint's
|
|
79
|
-
// auth check without establishing a session.
|
|
80
|
-
body: JSON.stringify({
|
|
81
|
-
jsonrpc: "2.0",
|
|
82
|
-
id: 1,
|
|
83
|
-
method: "initialize",
|
|
84
|
-
params: {},
|
|
85
|
-
}),
|
|
86
|
-
signal: AbortSignal.timeout(OAUTH_PROBE_TIMEOUT_MS),
|
|
87
|
-
});
|
|
88
|
-
if (response.status !== 401)
|
|
89
|
-
return null;
|
|
90
|
-
const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
|
|
91
|
-
if (!isOAuthChallenge(wwwAuthenticate))
|
|
92
|
-
return null;
|
|
93
|
-
return new OAuthRequiredError(slug, parseResourceMetadataUrl(wwwAuthenticate));
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
63
|
+
const outcome = await probeEndpointAuth(url, headers, {
|
|
64
|
+
fetchImpl: fetch,
|
|
65
|
+
timeoutMs: OAUTH_PROBE_TIMEOUT_MS,
|
|
66
|
+
clientName: PROBE_CLIENT_NAME,
|
|
67
|
+
});
|
|
68
|
+
if (outcome.kind !== "oauth")
|
|
96
69
|
return null;
|
|
97
|
-
|
|
70
|
+
return new OAuthRequiredError(slug, outcome.resourceMetadataUrl);
|
|
98
71
|
}
|
|
99
72
|
//# sourceMappingURL=mcp-oauth-detect.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp-oauth-detect.js","sourceRoot":"","sources":["../../src/shared/mcp-oauth-detect.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"mcp-oauth-detect.js","sourceRoot":"","sources":["../../src/shared/mcp-oauth-detect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAEhE,OAAO,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,6BAA6B,CAAC;AAEzF,oFAAoF;AACpF,MAAM,sBAAsB,GAAG,MAAM,CAAC;AAEtC,mEAAmE;AACnE,MAAM,iBAAiB,GAAG,gBAAgB,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAEzB;IACA;IAFlB,YACkB,UAAkB,EAClB,mBAA4B;QAE5C,KAAK,CACH,eAAe,UAAU,6CAA6C;YACpE,yEAAyE;YACzE,mEAAmE,CACtE,CAAC;QAPc,eAAU,GAAV,UAAU,CAAQ;QAClB,wBAAmB,GAAnB,mBAAmB,CAAS;QAO5C,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,GAAW,EACX,OAA2C,EAC3C,IAAY;IAEZ,MAAM,OAAO,GAAG,MAAM,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE;QACpD,SAAS,EAAE,KAAK;QAChB,SAAS,EAAE,sBAAsB;QACjC,UAAU,EAAE,iBAAiB;KAC9B,CAAC,CAAC;IACH,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO;QAAE,OAAO,IAAI,CAAC;IAC1C,OAAO,IAAI,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;AACnE,CAAC"}
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* with network reach to internal services and the cloud metadata endpoint —
|
|
9
9
|
* without this guard, "fetch a URL" becomes "read my pod's credentials".
|
|
10
10
|
*
|
|
11
|
+
* The address rule itself (which ranges each posture refuses, how an
|
|
12
|
+
* IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
|
|
13
|
+
* one home, `@stigmer/outbound/egress`, shared with the control plane, which
|
|
14
|
+
* dials user-supplied URLs too. This module keeps what is the runner's:
|
|
15
|
+
* deriving the posture from its mode, and the sentence a model reads.
|
|
16
|
+
*
|
|
11
17
|
* Posture is mode-aware because locality differs, not trust:
|
|
12
18
|
*
|
|
13
19
|
* - "strict" (managed cloud runners): loopback, RFC 1918 private,
|
|
@@ -28,12 +34,11 @@
|
|
|
28
34
|
*
|
|
29
35
|
* Validation runs on the addresses DNS resolves to immediately before the
|
|
30
36
|
* request is dispatched, and the caller re-validates every redirect hop.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* without replacing the dispatcher, and the strict posture's range blocks
|
|
34
|
-
* make the rebinding payoff (an internal address) unreachable anyway.
|
|
37
|
+
* The accepted DNS-rebinding limitation is stated in the shared check's
|
|
38
|
+
* header.
|
|
35
39
|
*/
|
|
36
|
-
|
|
40
|
+
import { type EgressPosture } from "@stigmer/outbound/egress";
|
|
41
|
+
export type GuardPosture = EgressPosture;
|
|
37
42
|
/** Thrown for every guard rejection; message is safe to surface to the model. */
|
|
38
43
|
export declare class UrlGuardError extends Error {
|
|
39
44
|
constructor(message: string);
|
package/dist/tools/url-guard.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* with network reach to internal services and the cloud metadata endpoint —
|
|
9
9
|
* without this guard, "fetch a URL" becomes "read my pod's credentials".
|
|
10
10
|
*
|
|
11
|
+
* The address rule itself (which ranges each posture refuses, how an
|
|
12
|
+
* IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
|
|
13
|
+
* one home, `@stigmer/outbound/egress`, shared with the control plane, which
|
|
14
|
+
* dials user-supplied URLs too. This module keeps what is the runner's:
|
|
15
|
+
* deriving the posture from its mode, and the sentence a model reads.
|
|
16
|
+
*
|
|
11
17
|
* Posture is mode-aware because locality differs, not trust:
|
|
12
18
|
*
|
|
13
19
|
* - "strict" (managed cloud runners): loopback, RFC 1918 private,
|
|
@@ -28,13 +34,10 @@
|
|
|
28
34
|
*
|
|
29
35
|
* Validation runs on the addresses DNS resolves to immediately before the
|
|
30
36
|
* request is dispatched, and the caller re-validates every redirect hop.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* without replacing the dispatcher, and the strict posture's range blocks
|
|
34
|
-
* make the rebinding payoff (an internal address) unreachable anyway.
|
|
37
|
+
* The accepted DNS-rebinding limitation is stated in the shared check's
|
|
38
|
+
* header.
|
|
35
39
|
*/
|
|
36
|
-
import {
|
|
37
|
-
import { isIP } from "node:net";
|
|
40
|
+
import { checkEgress, egressPolicyForPosture, nodeLookup, blockedReason as sharedBlockedReason, } from "@stigmer/outbound/egress";
|
|
38
41
|
/** Thrown for every guard rejection; message is safe to surface to the model. */
|
|
39
42
|
export class UrlGuardError extends Error {
|
|
40
43
|
constructor(message) {
|
|
@@ -56,6 +59,7 @@ export function resolveGuardPosture(mode, env = process.env) {
|
|
|
56
59
|
return "strict";
|
|
57
60
|
return mode === "cloud" ? "strict" : "relaxed";
|
|
58
61
|
}
|
|
62
|
+
const lookup = nodeLookup();
|
|
59
63
|
/**
|
|
60
64
|
* Validate a URL for fetching under the given posture.
|
|
61
65
|
*
|
|
@@ -67,41 +71,28 @@ export function resolveGuardPosture(mode, env = process.env) {
|
|
|
67
71
|
* @throws UrlGuardError with a model-readable reason on rejection.
|
|
68
72
|
*/
|
|
69
73
|
export async function validateFetchUrl(rawUrl, posture) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
url
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
74
|
+
const check = await checkEgress(rawUrl, egressPolicyForPosture(posture), { lookup });
|
|
75
|
+
if (check.ok)
|
|
76
|
+
return check.url;
|
|
77
|
+
const refusal = check.refusal;
|
|
78
|
+
switch (refusal.kind) {
|
|
79
|
+
case "invalid-url":
|
|
80
|
+
throw new UrlGuardError(`Invalid URL: ${refusal.url}`);
|
|
81
|
+
case "unsupported-scheme":
|
|
82
|
+
throw new UrlGuardError(`Unsupported URL scheme "${refusal.scheme}" — only http and https are allowed.`);
|
|
83
|
+
case "unresolvable":
|
|
84
|
+
throw new UrlGuardError(`Could not resolve hostname: ${refusal.hostname}`);
|
|
85
|
+
case "blocked":
|
|
86
|
+
throw new UrlGuardError(`Refusing to fetch ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address that this runner does not fetch from.`);
|
|
87
|
+
case "too-many-redirects":
|
|
88
|
+
// The check itself never follows a redirect; web_fetch walks hops
|
|
89
|
+
// one validateFetchUrl at a time, so this refusal cannot arise here.
|
|
90
|
+
throw new UrlGuardError(`Refusing to follow more than ${refusal.hops} redirects.`);
|
|
91
|
+
default: {
|
|
92
|
+
const exhaustive = refusal;
|
|
93
|
+
throw new UrlGuardError(String(exhaustive));
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
|
-
return url;
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* Resolve a hostname to all of its addresses. Literal IPs pass through
|
|
91
|
-
* (bracketed IPv6 hosts arrive from URL.hostname still bracketed).
|
|
92
|
-
*/
|
|
93
|
-
async function resolveAddresses(hostname) {
|
|
94
|
-
const literal = hostname.replace(/^\[|\]$/g, "");
|
|
95
|
-
if (isIP(literal) !== 0) {
|
|
96
|
-
return [literal];
|
|
97
|
-
}
|
|
98
|
-
try {
|
|
99
|
-
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
100
|
-
return records.map((r) => r.address);
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
throw new UrlGuardError(`Could not resolve hostname: ${hostname}`);
|
|
104
|
-
}
|
|
105
96
|
}
|
|
106
97
|
/**
|
|
107
98
|
* Classify an IP address against the posture's blocked ranges.
|
|
@@ -109,103 +100,6 @@ async function resolveAddresses(hostname) {
|
|
|
109
100
|
* @returns a human-readable range name when blocked, or null when allowed.
|
|
110
101
|
*/
|
|
111
102
|
export function blockedReason(address, posture) {
|
|
112
|
-
|
|
113
|
-
if (family === 4) {
|
|
114
|
-
return blockedReasonV4(address, posture);
|
|
115
|
-
}
|
|
116
|
-
if (family === 6) {
|
|
117
|
-
const groups = expandV6(address);
|
|
118
|
-
if (!groups)
|
|
119
|
-
return "unrecognized";
|
|
120
|
-
// IPv4-mapped IPv6 (::ffff:a.b.c.d, in dotted OR hex form) — classify
|
|
121
|
-
// the embedded IPv4 so the mapping cannot smuggle a blocked v4 address
|
|
122
|
-
// past the guard.
|
|
123
|
-
const embedded = extractMappedV4(groups);
|
|
124
|
-
if (embedded) {
|
|
125
|
-
return blockedReasonV4(embedded, posture);
|
|
126
|
-
}
|
|
127
|
-
return blockedReasonV6(groups, posture);
|
|
128
|
-
}
|
|
129
|
-
// Unparseable — fail closed; only real addresses get sockets.
|
|
130
|
-
return "unrecognized";
|
|
131
|
-
}
|
|
132
|
-
function blockedReasonV4(address, posture) {
|
|
133
|
-
const octets = address.split(".").map(Number);
|
|
134
|
-
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
|
|
135
|
-
return "unrecognized";
|
|
136
|
-
}
|
|
137
|
-
const [a, b] = octets;
|
|
138
|
-
// Link-local (169.254.0.0/16) hosts the cloud metadata service at
|
|
139
|
-
// 169.254.169.254 — blocked under BOTH postures.
|
|
140
|
-
if (a === 169 && b === 254)
|
|
141
|
-
return "link-local (cloud metadata)";
|
|
142
|
-
if (posture === "relaxed")
|
|
143
|
-
return null;
|
|
144
|
-
if (a === 127)
|
|
145
|
-
return "loopback";
|
|
146
|
-
if (a === 0)
|
|
147
|
-
return "unspecified";
|
|
148
|
-
if (a === 10)
|
|
149
|
-
return "private (RFC 1918)";
|
|
150
|
-
if (a === 172 && b >= 16 && b <= 31)
|
|
151
|
-
return "private (RFC 1918)";
|
|
152
|
-
if (a === 192 && b === 168)
|
|
153
|
-
return "private (RFC 1918)";
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
function blockedReasonV6(groups, posture) {
|
|
157
|
-
// fe80::/10 — IPv6 link-local, the v6 sibling of the metadata range;
|
|
158
|
-
// blocked under BOTH postures for symmetry with v4.
|
|
159
|
-
if ((groups[0] & 0xffc0) === 0xfe80)
|
|
160
|
-
return "link-local (cloud metadata)";
|
|
161
|
-
if (posture === "relaxed")
|
|
162
|
-
return null;
|
|
163
|
-
const allZero = groups.every((g) => g === 0);
|
|
164
|
-
if (allZero)
|
|
165
|
-
return "unspecified";
|
|
166
|
-
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
|
|
167
|
-
return "loopback";
|
|
168
|
-
// fc00::/7 — unique local (private) addresses.
|
|
169
|
-
if ((groups[0] & 0xfe00) === 0xfc00)
|
|
170
|
-
return "private (unique local)";
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Expand an IPv6 address (already validated by isIP) into its 8 groups.
|
|
175
|
-
* Handles `::` compression, a trailing dotted-IPv4 tail, and zone suffixes.
|
|
176
|
-
*/
|
|
177
|
-
function expandV6(address) {
|
|
178
|
-
let text = address.toLowerCase().split("%")[0];
|
|
179
|
-
// Convert a dotted-IPv4 tail (e.g. ::ffff:127.0.0.1) into two hex groups.
|
|
180
|
-
const v4Tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
181
|
-
if (v4Tail) {
|
|
182
|
-
const octets = v4Tail[1].split(".").map(Number);
|
|
183
|
-
if (octets.length !== 4 || octets.some((o) => o > 255))
|
|
184
|
-
return null;
|
|
185
|
-
const hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
186
|
-
text = text.slice(0, -v4Tail[1].length) + hex;
|
|
187
|
-
}
|
|
188
|
-
const halves = text.split("::");
|
|
189
|
-
if (halves.length > 2)
|
|
190
|
-
return null;
|
|
191
|
-
const parseHalf = (half) => half === "" ? [] : half.split(":").map((g) => parseInt(g, 16));
|
|
192
|
-
const head = parseHalf(halves[0]);
|
|
193
|
-
const tail = halves.length === 2 ? parseHalf(halves[1]) : [];
|
|
194
|
-
const fill = 8 - head.length - tail.length;
|
|
195
|
-
if (halves.length === 2 ? fill < 0 : head.length !== 8)
|
|
196
|
-
return null;
|
|
197
|
-
const groups = [...head, ...(halves.length === 2 ? Array(fill).fill(0) : []), ...tail];
|
|
198
|
-
if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) {
|
|
199
|
-
return null;
|
|
200
|
-
}
|
|
201
|
-
return groups;
|
|
202
|
-
}
|
|
203
|
-
/** Extract the IPv4 payload from IPv4-mapped groups (::ffff:0:0/96), if any. */
|
|
204
|
-
function extractMappedV4(groups) {
|
|
205
|
-
const isMapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
|
|
206
|
-
if (!isMapped)
|
|
207
|
-
return null;
|
|
208
|
-
const [hi, lo] = [groups[6], groups[7]];
|
|
209
|
-
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
103
|
+
return sharedBlockedReason(address, posture);
|
|
210
104
|
}
|
|
211
105
|
//# sourceMappingURL=url-guard.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"url-guard.js","sourceRoot":"","sources":["../../src/tools/url-guard.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"url-guard.js","sourceRoot":"","sources":["../../src/tools/url-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EACL,WAAW,EACX,sBAAsB,EACtB,UAAU,EACV,aAAa,IAAI,mBAAmB,GAErC,MAAM,0BAA0B,CAAC;AAIlC,iFAAiF;AACjF,MAAM,OAAO,aAAc,SAAQ,KAAK;IACtC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAAuB,EACvB,MAAyB,OAAO,CAAC,GAAG;IAEpC,MAAM,QAAQ,GAAG,GAAG,CAAC,+BAA+B,CAAC;IACrD,IAAI,QAAQ,KAAK,MAAM;QAAE,OAAO,SAAS,CAAC;IAC1C,IAAI,QAAQ,KAAK,OAAO;QAAE,OAAO,QAAQ,CAAC;IAC1C,OAAO,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;AACjD,CAAC;AAED,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;AAE5B;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,MAAc,EACd,OAAqB;IAErB,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,MAAM,EAAE,sBAAsB,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IACrF,IAAI,KAAK,CAAC,EAAE;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC;IAE/B,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;IAC9B,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACrB,KAAK,aAAa;YAChB,MAAM,IAAI,aAAa,CAAC,gBAAgB,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACzD,KAAK,oBAAoB;YACvB,MAAM,IAAI,aAAa,CACrB,2BAA2B,OAAO,CAAC,MAAM,sCAAsC,CAChF,CAAC;QACJ,KAAK,cAAc;YACjB,MAAM,IAAI,aAAa,CAAC,+BAA+B,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC7E,KAAK,SAAS;YACZ,MAAM,IAAI,aAAa,CACrB,qBAAqB,OAAO,CAAC,QAAQ,oBAAoB,OAAO,CAAC,OAAO,OAAO,OAAO,CAAC,MAAM,gDAAgD,CAC9I,CAAC;QACJ,KAAK,oBAAoB;YACvB,kEAAkE;YAClE,qEAAqE;YACrE,MAAM,IAAI,aAAa,CAAC,gCAAgC,OAAO,CAAC,IAAI,aAAa,CAAC,CAAC;QACrF,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,OAAO,CAAC;YAClC,MAAM,IAAI,aAAa,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC;QAC9C,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,OAAe,EAAE,OAAqB;IAClE,OAAO,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;AAC/C,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -104,9 +104,10 @@
|
|
|
104
104
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
105
105
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
106
106
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
107
|
-
"@stigmer/
|
|
108
|
-
"@stigmer/
|
|
109
|
-
"@stigmer/
|
|
107
|
+
"@stigmer/outbound": "3.19.0",
|
|
108
|
+
"@stigmer/protos": "3.19.0",
|
|
109
|
+
"@stigmer/temporal-codecs": "3.19.0",
|
|
110
|
+
"@stigmer/zip-structure": "3.19.0",
|
|
110
111
|
"@temporalio/activity": "1.16.2",
|
|
111
112
|
"@temporalio/common": "1.16.2",
|
|
112
113
|
"@temporalio/interceptors-opentelemetry": "1.16.2",
|
|
@@ -144,4 +144,24 @@ describe("detectOAuthChallenge", () => {
|
|
|
144
144
|
);
|
|
145
145
|
expect(init?.method).toBe("POST");
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
it("sends a complete initialize naming the runner, so servers that validate before they authenticate reach their 401 (#1188)", async () => {
|
|
149
|
+
const fetchMock = vi.fn(
|
|
150
|
+
async (_url: string | URL, _init?: RequestInit) =>
|
|
151
|
+
new Response(null, { status: 200 }),
|
|
152
|
+
);
|
|
153
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
154
|
+
|
|
155
|
+
await detectOAuthChallenge("https://mcp.example.com/mcp", undefined, "example");
|
|
156
|
+
|
|
157
|
+
const init = fetchMock.mock.calls[0]?.[1];
|
|
158
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
159
|
+
method: string;
|
|
160
|
+
params: { protocolVersion?: string; capabilities?: object; clientInfo?: { name: string } };
|
|
161
|
+
};
|
|
162
|
+
expect(body.method).toBe("initialize");
|
|
163
|
+
expect(body.params.protocolVersion).toBe("2025-06-18");
|
|
164
|
+
expect(body.params.capabilities).toEqual({});
|
|
165
|
+
expect(body.params.clientInfo?.name).toBe("stigmer-runner");
|
|
166
|
+
});
|
|
147
167
|
});
|
|
@@ -14,13 +14,27 @@
|
|
|
14
14
|
* OAuthRequiredError} whose message tells the user to connect via OAuth instead
|
|
15
15
|
* of a manual token.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* The rule and the request have one home, `@stigmer/outbound/mcp-oauth`,
|
|
18
|
+
* shared with the control plane (which asks the same question of a URL-only
|
|
19
|
+
* server at save time, so the Sign in button exists before the first run)
|
|
20
|
+
* and the catalogue audit. The request is the complete `initialize`
|
|
21
|
+
* (protocol version, capabilities, client info): several hosted servers
|
|
22
|
+
* validate the request before they check authentication and answer a bare
|
|
23
|
+
* one with HTTP 200 and a JSON-RPC error, so the bare request this module
|
|
24
|
+
* once sent never reached their 401 (stigmer #1188). What stays here is the
|
|
25
|
+
* runner's own: its deadline, its name in the handshake, and the sentence
|
|
26
|
+
* the user reads.
|
|
20
27
|
*/
|
|
28
|
+
import { probeEndpointAuth } from "@stigmer/outbound/mcp-oauth";
|
|
21
29
|
|
|
30
|
+
export { isOAuthChallenge, parseResourceMetadataUrl } from "@stigmer/outbound/mcp-oauth";
|
|
31
|
+
|
|
32
|
+
/** The failure path can afford a patient probe; a save cannot, and uses its own. */
|
|
22
33
|
const OAUTH_PROBE_TIMEOUT_MS = 10_000;
|
|
23
34
|
|
|
35
|
+
/** How the runner names itself in the handshake's `clientInfo`. */
|
|
36
|
+
const PROBE_CLIENT_NAME = "stigmer-runner";
|
|
37
|
+
|
|
24
38
|
/**
|
|
25
39
|
* Raised when an HTTP MCP endpoint answers with an OAuth authentication
|
|
26
40
|
* challenge. The message is self-contained and user-facing: it survives the
|
|
@@ -43,28 +57,6 @@ export class OAuthRequiredError extends Error {
|
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
/**
|
|
47
|
-
* Whether a `WWW-Authenticate` header value is an OAuth challenge.
|
|
48
|
-
*
|
|
49
|
-
* The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
|
|
50
|
-
* require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
|
|
51
|
-
* pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
|
|
52
|
-
* an OAuth requirement) is not misclassified.
|
|
53
|
-
*/
|
|
54
|
-
export function isOAuthChallenge(wwwAuthenticate: string): boolean {
|
|
55
|
-
const value = wwwAuthenticate.toLowerCase();
|
|
56
|
-
if (!value.includes("bearer")) return false;
|
|
57
|
-
return value.includes("oauth") || value.includes("resource_metadata");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
|
|
61
|
-
export function parseResourceMetadataUrl(
|
|
62
|
-
wwwAuthenticate: string,
|
|
63
|
-
): string | undefined {
|
|
64
|
-
const match = /resource_metadata="([^"]+)"/i.exec(wwwAuthenticate);
|
|
65
|
-
return match?.[1];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
60
|
/**
|
|
69
61
|
* Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
|
|
70
62
|
* challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
|
|
@@ -78,35 +70,11 @@ export async function detectOAuthChallenge(
|
|
|
78
70
|
headers: Record<string, string> | undefined,
|
|
79
71
|
slug: string,
|
|
80
72
|
): Promise<OAuthRequiredError | null> {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
// A minimal MCP initialize request — enough to trigger the endpoint's
|
|
90
|
-
// auth check without establishing a session.
|
|
91
|
-
body: JSON.stringify({
|
|
92
|
-
jsonrpc: "2.0",
|
|
93
|
-
id: 1,
|
|
94
|
-
method: "initialize",
|
|
95
|
-
params: {},
|
|
96
|
-
}),
|
|
97
|
-
signal: AbortSignal.timeout(OAUTH_PROBE_TIMEOUT_MS),
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
if (response.status !== 401) return null;
|
|
101
|
-
|
|
102
|
-
const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
|
|
103
|
-
if (!isOAuthChallenge(wwwAuthenticate)) return null;
|
|
104
|
-
|
|
105
|
-
return new OAuthRequiredError(
|
|
106
|
-
slug,
|
|
107
|
-
parseResourceMetadataUrl(wwwAuthenticate),
|
|
108
|
-
);
|
|
109
|
-
} catch {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
73
|
+
const outcome = await probeEndpointAuth(url, headers, {
|
|
74
|
+
fetchImpl: fetch,
|
|
75
|
+
timeoutMs: OAUTH_PROBE_TIMEOUT_MS,
|
|
76
|
+
clientName: PROBE_CLIENT_NAME,
|
|
77
|
+
});
|
|
78
|
+
if (outcome.kind !== "oauth") return null;
|
|
79
|
+
return new OAuthRequiredError(slug, outcome.resourceMetadataUrl);
|
|
112
80
|
}
|
package/src/tools/url-guard.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* with network reach to internal services and the cloud metadata endpoint —
|
|
9
9
|
* without this guard, "fetch a URL" becomes "read my pod's credentials".
|
|
10
10
|
*
|
|
11
|
+
* The address rule itself (which ranges each posture refuses, how an
|
|
12
|
+
* IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
|
|
13
|
+
* one home, `@stigmer/outbound/egress`, shared with the control plane, which
|
|
14
|
+
* dials user-supplied URLs too. This module keeps what is the runner's:
|
|
15
|
+
* deriving the posture from its mode, and the sentence a model reads.
|
|
16
|
+
*
|
|
11
17
|
* Posture is mode-aware because locality differs, not trust:
|
|
12
18
|
*
|
|
13
19
|
* - "strict" (managed cloud runners): loopback, RFC 1918 private,
|
|
@@ -28,16 +34,18 @@
|
|
|
28
34
|
*
|
|
29
35
|
* Validation runs on the addresses DNS resolves to immediately before the
|
|
30
36
|
* request is dispatched, and the caller re-validates every redirect hop.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* without replacing the dispatcher, and the strict posture's range blocks
|
|
34
|
-
* make the rebinding payoff (an internal address) unreachable anyway.
|
|
37
|
+
* The accepted DNS-rebinding limitation is stated in the shared check's
|
|
38
|
+
* header.
|
|
35
39
|
*/
|
|
40
|
+
import {
|
|
41
|
+
checkEgress,
|
|
42
|
+
egressPolicyForPosture,
|
|
43
|
+
nodeLookup,
|
|
44
|
+
blockedReason as sharedBlockedReason,
|
|
45
|
+
type EgressPosture,
|
|
46
|
+
} from "@stigmer/outbound/egress";
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
import { isIP } from "node:net";
|
|
39
|
-
|
|
40
|
-
export type GuardPosture = "strict" | "relaxed";
|
|
48
|
+
export type GuardPosture = EgressPosture;
|
|
41
49
|
|
|
42
50
|
/** Thrown for every guard rejection; message is safe to surface to the model. */
|
|
43
51
|
export class UrlGuardError extends Error {
|
|
@@ -63,6 +71,8 @@ export function resolveGuardPosture(
|
|
|
63
71
|
return mode === "cloud" ? "strict" : "relaxed";
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
const lookup = nodeLookup();
|
|
75
|
+
|
|
66
76
|
/**
|
|
67
77
|
* Validate a URL for fetching under the given posture.
|
|
68
78
|
*
|
|
@@ -77,48 +87,32 @@ export async function validateFetchUrl(
|
|
|
77
87
|
rawUrl: string,
|
|
78
88
|
posture: GuardPosture,
|
|
79
89
|
): Promise<URL> {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
throw new UrlGuardError(
|
|
89
|
-
`Unsupported URL scheme "${url.protocol.replace(/:$/, "")}" — only http and https are allowed.`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const addresses = await resolveAddresses(url.hostname);
|
|
94
|
-
for (const address of addresses) {
|
|
95
|
-
const reason = blockedReason(address, posture);
|
|
96
|
-
if (reason) {
|
|
90
|
+
const check = await checkEgress(rawUrl, egressPolicyForPosture(posture), { lookup });
|
|
91
|
+
if (check.ok) return check.url;
|
|
92
|
+
|
|
93
|
+
const refusal = check.refusal;
|
|
94
|
+
switch (refusal.kind) {
|
|
95
|
+
case "invalid-url":
|
|
96
|
+
throw new UrlGuardError(`Invalid URL: ${refusal.url}`);
|
|
97
|
+
case "unsupported-scheme":
|
|
97
98
|
throw new UrlGuardError(
|
|
98
|
-
`
|
|
99
|
+
`Unsupported URL scheme "${refusal.scheme}" — only http and https are allowed.`,
|
|
99
100
|
);
|
|
101
|
+
case "unresolvable":
|
|
102
|
+
throw new UrlGuardError(`Could not resolve hostname: ${refusal.hostname}`);
|
|
103
|
+
case "blocked":
|
|
104
|
+
throw new UrlGuardError(
|
|
105
|
+
`Refusing to fetch ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address that this runner does not fetch from.`,
|
|
106
|
+
);
|
|
107
|
+
case "too-many-redirects":
|
|
108
|
+
// The check itself never follows a redirect; web_fetch walks hops
|
|
109
|
+
// one validateFetchUrl at a time, so this refusal cannot arise here.
|
|
110
|
+
throw new UrlGuardError(`Refusing to follow more than ${refusal.hops} redirects.`);
|
|
111
|
+
default: {
|
|
112
|
+
const exhaustive: never = refusal;
|
|
113
|
+
throw new UrlGuardError(String(exhaustive));
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
|
-
|
|
103
|
-
return url;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Resolve a hostname to all of its addresses. Literal IPs pass through
|
|
108
|
-
* (bracketed IPv6 hosts arrive from URL.hostname still bracketed).
|
|
109
|
-
*/
|
|
110
|
-
async function resolveAddresses(hostname: string): Promise<string[]> {
|
|
111
|
-
const literal = hostname.replace(/^\[|\]$/g, "");
|
|
112
|
-
if (isIP(literal) !== 0) {
|
|
113
|
-
return [literal];
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
118
|
-
return records.map((r) => r.address);
|
|
119
|
-
} catch {
|
|
120
|
-
throw new UrlGuardError(`Could not resolve hostname: ${hostname}`);
|
|
121
|
-
}
|
|
122
116
|
}
|
|
123
117
|
|
|
124
118
|
/**
|
|
@@ -127,106 +121,5 @@ async function resolveAddresses(hostname: string): Promise<string[]> {
|
|
|
127
121
|
* @returns a human-readable range name when blocked, or null when allowed.
|
|
128
122
|
*/
|
|
129
123
|
export function blockedReason(address: string, posture: GuardPosture): string | null {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (family === 4) {
|
|
133
|
-
return blockedReasonV4(address, posture);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (family === 6) {
|
|
137
|
-
const groups = expandV6(address);
|
|
138
|
-
if (!groups) return "unrecognized";
|
|
139
|
-
|
|
140
|
-
// IPv4-mapped IPv6 (::ffff:a.b.c.d, in dotted OR hex form) — classify
|
|
141
|
-
// the embedded IPv4 so the mapping cannot smuggle a blocked v4 address
|
|
142
|
-
// past the guard.
|
|
143
|
-
const embedded = extractMappedV4(groups);
|
|
144
|
-
if (embedded) {
|
|
145
|
-
return blockedReasonV4(embedded, posture);
|
|
146
|
-
}
|
|
147
|
-
return blockedReasonV6(groups, posture);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Unparseable — fail closed; only real addresses get sockets.
|
|
151
|
-
return "unrecognized";
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function blockedReasonV4(address: string, posture: GuardPosture): string | null {
|
|
155
|
-
const octets = address.split(".").map(Number);
|
|
156
|
-
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
|
|
157
|
-
return "unrecognized";
|
|
158
|
-
}
|
|
159
|
-
const [a, b] = octets;
|
|
160
|
-
|
|
161
|
-
// Link-local (169.254.0.0/16) hosts the cloud metadata service at
|
|
162
|
-
// 169.254.169.254 — blocked under BOTH postures.
|
|
163
|
-
if (a === 169 && b === 254) return "link-local (cloud metadata)";
|
|
164
|
-
|
|
165
|
-
if (posture === "relaxed") return null;
|
|
166
|
-
|
|
167
|
-
if (a === 127) return "loopback";
|
|
168
|
-
if (a === 0) return "unspecified";
|
|
169
|
-
if (a === 10) return "private (RFC 1918)";
|
|
170
|
-
if (a === 172 && b >= 16 && b <= 31) return "private (RFC 1918)";
|
|
171
|
-
if (a === 192 && b === 168) return "private (RFC 1918)";
|
|
172
|
-
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function blockedReasonV6(groups: readonly number[], posture: GuardPosture): string | null {
|
|
177
|
-
// fe80::/10 — IPv6 link-local, the v6 sibling of the metadata range;
|
|
178
|
-
// blocked under BOTH postures for symmetry with v4.
|
|
179
|
-
if ((groups[0] & 0xffc0) === 0xfe80) return "link-local (cloud metadata)";
|
|
180
|
-
|
|
181
|
-
if (posture === "relaxed") return null;
|
|
182
|
-
|
|
183
|
-
const allZero = groups.every((g) => g === 0);
|
|
184
|
-
if (allZero) return "unspecified";
|
|
185
|
-
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
|
|
186
|
-
// fc00::/7 — unique local (private) addresses.
|
|
187
|
-
if ((groups[0] & 0xfe00) === 0xfc00) return "private (unique local)";
|
|
188
|
-
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Expand an IPv6 address (already validated by isIP) into its 8 groups.
|
|
194
|
-
* Handles `::` compression, a trailing dotted-IPv4 tail, and zone suffixes.
|
|
195
|
-
*/
|
|
196
|
-
function expandV6(address: string): number[] | null {
|
|
197
|
-
let text = address.toLowerCase().split("%")[0];
|
|
198
|
-
|
|
199
|
-
// Convert a dotted-IPv4 tail (e.g. ::ffff:127.0.0.1) into two hex groups.
|
|
200
|
-
const v4Tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
201
|
-
if (v4Tail) {
|
|
202
|
-
const octets = v4Tail[1].split(".").map(Number);
|
|
203
|
-
if (octets.length !== 4 || octets.some((o) => o > 255)) return null;
|
|
204
|
-
const hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
205
|
-
text = text.slice(0, -v4Tail[1].length) + hex;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const halves = text.split("::");
|
|
209
|
-
if (halves.length > 2) return null;
|
|
210
|
-
|
|
211
|
-
const parseHalf = (half: string): number[] =>
|
|
212
|
-
half === "" ? [] : half.split(":").map((g) => parseInt(g, 16));
|
|
213
|
-
|
|
214
|
-
const head = parseHalf(halves[0]);
|
|
215
|
-
const tail = halves.length === 2 ? parseHalf(halves[1]) : [];
|
|
216
|
-
const fill = 8 - head.length - tail.length;
|
|
217
|
-
if (halves.length === 2 ? fill < 0 : head.length !== 8) return null;
|
|
218
|
-
|
|
219
|
-
const groups = [...head, ...(halves.length === 2 ? Array(fill).fill(0) : []), ...tail];
|
|
220
|
-
if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
return groups;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Extract the IPv4 payload from IPv4-mapped groups (::ffff:0:0/96), if any. */
|
|
227
|
-
function extractMappedV4(groups: readonly number[]): string | null {
|
|
228
|
-
const isMapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
|
|
229
|
-
if (!isMapped) return null;
|
|
230
|
-
const [hi, lo] = [groups[6], groups[7]];
|
|
231
|
-
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
124
|
+
return sharedBlockedReason(address, posture);
|
|
232
125
|
}
|