@stigmer/outbound 3.18.1-dev.20260919070736
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/LICENSE +190 -0
- package/README.md +22 -0
- package/egress/address.d.ts +23 -0
- package/egress/address.d.ts.map +1 -0
- package/egress/address.js +148 -0
- package/egress/address.js.map +1 -0
- package/egress/check.d.ts +50 -0
- package/egress/check.d.ts.map +1 -0
- package/egress/check.js +127 -0
- package/egress/check.js.map +1 -0
- package/egress/fetch.d.ts +52 -0
- package/egress/fetch.d.ts.map +1 -0
- package/egress/fetch.js +59 -0
- package/egress/fetch.js.map +1 -0
- package/egress/index.d.ts +10 -0
- package/egress/index.d.ts.map +1 -0
- package/egress/index.js +10 -0
- package/egress/index.js.map +1 -0
- package/egress/node-lookup.d.ts +4 -0
- package/egress/node-lookup.d.ts.map +1 -0
- package/egress/node-lookup.js +20 -0
- package/egress/node-lookup.js.map +1 -0
- package/index.d.ts +11 -0
- package/index.d.ts.map +1 -0
- package/index.js +11 -0
- package/index.js.map +1 -0
- package/mcp-oauth/challenge.d.ts +25 -0
- package/mcp-oauth/challenge.d.ts.map +1 -0
- package/mcp-oauth/challenge.js +33 -0
- package/mcp-oauth/challenge.js.map +1 -0
- package/mcp-oauth/index.d.ts +11 -0
- package/mcp-oauth/index.d.ts.map +1 -0
- package/mcp-oauth/index.js +11 -0
- package/mcp-oauth/index.js.map +1 -0
- package/mcp-oauth/metadata.d.ts +86 -0
- package/mcp-oauth/metadata.d.ts.map +1 -0
- package/mcp-oauth/metadata.js +113 -0
- package/mcp-oauth/metadata.js.map +1 -0
- package/mcp-oauth/probe.d.ts +68 -0
- package/mcp-oauth/probe.d.ts.map +1 -0
- package/mcp-oauth/probe.js +35 -0
- package/mcp-oauth/probe.js.map +1 -0
- package/mcp-oauth/request.d.ts +44 -0
- package/mcp-oauth/request.d.ts.map +1 -0
- package/mcp-oauth/request.js +71 -0
- package/mcp-oauth/request.js.map +1 -0
- package/package.json +42 -0
- package/src/__tests__/address.test.ts +88 -0
- package/src/__tests__/challenge.test.ts +41 -0
- package/src/__tests__/check.test.ts +98 -0
- package/src/__tests__/fetch.test.ts +161 -0
- package/src/__tests__/metadata.test.ts +171 -0
- package/src/__tests__/probe.test.ts +98 -0
- package/src/egress/address.ts +168 -0
- package/src/egress/check.ts +163 -0
- package/src/egress/fetch.ts +104 -0
- package/src/egress/index.ts +17 -0
- package/src/egress/node-lookup.ts +22 -0
- package/src/index.ts +10 -0
- package/src/mcp-oauth/challenge.ts +33 -0
- package/src/mcp-oauth/index.ts +25 -0
- package/src/mcp-oauth/metadata.ts +185 -0
- package/src/mcp-oauth/probe.ts +91 -0
- package/src/mcp-oauth/request.ts +81 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the login-server walk: the RFC 9728 documents tried most specific
|
|
3
|
+
* first, the RFC 8414 well-known segment inserted BEFORE an issuer's path,
|
|
4
|
+
* OpenID's document as the fallback, the challenge's pointer preferred, and
|
|
5
|
+
* every attempt recorded so a caller can name the document it failed on.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import type { OutboundFetch } from "../egress/fetch.js";
|
|
10
|
+
import {
|
|
11
|
+
authorizationServerMetadataUrls,
|
|
12
|
+
protectedResourceMetadataUrls,
|
|
13
|
+
readAuthorizationServerMetadata,
|
|
14
|
+
resolveAuthorizationServers,
|
|
15
|
+
} from "../mcp-oauth/metadata.js";
|
|
16
|
+
|
|
17
|
+
const deps = { timeoutMs: 1_000 };
|
|
18
|
+
|
|
19
|
+
function serving(documents: Record<string, unknown | { status: number; body?: unknown }>): { fetchImpl: OutboundFetch; requested: string[] } {
|
|
20
|
+
const requested: string[] = [];
|
|
21
|
+
const fetchImpl: OutboundFetch = async (url) => {
|
|
22
|
+
const key = String(url);
|
|
23
|
+
requested.push(key);
|
|
24
|
+
const entry = documents[key];
|
|
25
|
+
if (entry === undefined) return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
|
|
26
|
+
if (typeof entry === "object" && entry !== null && "status" in entry && typeof (entry as { status: unknown }).status === "number") {
|
|
27
|
+
const { status, body } = entry as { status: number; body?: unknown };
|
|
28
|
+
return new Response(body === undefined ? null : JSON.stringify(body), { status });
|
|
29
|
+
}
|
|
30
|
+
return new Response(JSON.stringify(entry), { status: 200, headers: { "content-type": "application/json" } });
|
|
31
|
+
};
|
|
32
|
+
return { fetchImpl, requested };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ISSUER_DOCUMENT = {
|
|
36
|
+
issuer: "https://login.vendor.test",
|
|
37
|
+
authorization_endpoint: "https://login.vendor.test/authorize",
|
|
38
|
+
token_endpoint: "https://login.vendor.test/token",
|
|
39
|
+
registration_endpoint: "https://login.vendor.test/register",
|
|
40
|
+
scopes_supported: ["read"],
|
|
41
|
+
code_challenge_methods_supported: ["S256"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("protectedResourceMetadataUrls", () => {
|
|
45
|
+
it("tries the path-suffixed document first, then the bare one", () => {
|
|
46
|
+
expect(protectedResourceMetadataUrls(new URL("https://mcp.vendor.test/mcp/"))).toEqual([
|
|
47
|
+
"https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp",
|
|
48
|
+
"https://mcp.vendor.test/.well-known/oauth-protected-resource",
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("tries only the bare document for a root resource", () => {
|
|
53
|
+
expect(protectedResourceMetadataUrls(new URL("https://mcp.vendor.test/"))).toEqual(["https://mcp.vendor.test/.well-known/oauth-protected-resource"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("authorizationServerMetadataUrls", () => {
|
|
58
|
+
it("inserts the well-known segment before an issuer's path (RFC 8414 section 3), then falls back", () => {
|
|
59
|
+
expect(authorizationServerMetadataUrls(new URL("https://login.vendor.test/tenant"))).toEqual([
|
|
60
|
+
"https://login.vendor.test/.well-known/oauth-authorization-server/tenant",
|
|
61
|
+
"https://login.vendor.test/.well-known/oauth-authorization-server",
|
|
62
|
+
"https://login.vendor.test/tenant/.well-known/openid-configuration",
|
|
63
|
+
"https://login.vendor.test/.well-known/openid-configuration",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("for a bare issuer tries RFC 8414 at the origin first, the document the reader before this one read", () => {
|
|
68
|
+
expect(authorizationServerMetadataUrls(new URL("https://mcp.vendor.test"))).toEqual([
|
|
69
|
+
"https://mcp.vendor.test/.well-known/oauth-authorization-server",
|
|
70
|
+
"https://mcp.vendor.test/.well-known/openid-configuration",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("resolveAuthorizationServers", () => {
|
|
76
|
+
it("prefers the challenge's pointer and reads authorization_servers from it", async () => {
|
|
77
|
+
const { fetchImpl, requested } = serving({
|
|
78
|
+
"https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp": { resource: "https://mcp.vendor.test/mcp", authorization_servers: ["https://login.vendor.test"] },
|
|
79
|
+
});
|
|
80
|
+
const result = await resolveAuthorizationServers("https://mcp.vendor.test/mcp", "https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp", { ...deps, fetchImpl });
|
|
81
|
+
expect(result.issuers).toEqual(["https://login.vendor.test"]);
|
|
82
|
+
expect(requested).toEqual(["https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("without a pointer walks the well-known documents most specific first", async () => {
|
|
86
|
+
const { fetchImpl, requested } = serving({
|
|
87
|
+
"https://mcp.vendor.test/.well-known/oauth-protected-resource": { authorization_servers: ["https://login.vendor.test/tenant"] },
|
|
88
|
+
});
|
|
89
|
+
const result = await resolveAuthorizationServers("https://mcp.vendor.test/mcp", undefined, { ...deps, fetchImpl });
|
|
90
|
+
expect(result.issuers).toEqual(["https://login.vendor.test/tenant"]);
|
|
91
|
+
expect(requested).toEqual(["https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp", "https://mcp.vendor.test/.well-known/oauth-protected-resource"]);
|
|
92
|
+
expect(result.attempts).toEqual([
|
|
93
|
+
{ url: "https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp", status: 404 },
|
|
94
|
+
{ url: "https://mcp.vendor.test/.well-known/oauth-protected-resource", status: 200 },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("answers no issuers when no document names any, recording every attempt", async () => {
|
|
99
|
+
const { fetchImpl } = serving({});
|
|
100
|
+
const result = await resolveAuthorizationServers("https://mcp.vendor.test/mcp", undefined, { ...deps, fetchImpl });
|
|
101
|
+
expect(result.issuers).toEqual([]);
|
|
102
|
+
expect(result.attempts).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("records a network failure as an attempt and moves on", async () => {
|
|
106
|
+
const fetchImpl: OutboundFetch = async () => {
|
|
107
|
+
throw new Error("connect ECONNREFUSED");
|
|
108
|
+
};
|
|
109
|
+
const result = await resolveAuthorizationServers("https://mcp.vendor.test/", undefined, { ...deps, fetchImpl });
|
|
110
|
+
expect(result.issuers).toEqual([]);
|
|
111
|
+
expect(result.attempts).toEqual([{ url: "https://mcp.vendor.test/.well-known/oauth-protected-resource", error: "connect ECONNREFUSED" }]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("readAuthorizationServerMetadata", () => {
|
|
116
|
+
it("reads a path-bearing issuer through the inserted well-known segment", async () => {
|
|
117
|
+
const { fetchImpl, requested } = serving({
|
|
118
|
+
"https://login.vendor.test/.well-known/oauth-authorization-server/tenant": ISSUER_DOCUMENT,
|
|
119
|
+
});
|
|
120
|
+
const read = await readAuthorizationServerMetadata("https://login.vendor.test/tenant", { ...deps, fetchImpl });
|
|
121
|
+
expect(read.kind).toBe("found");
|
|
122
|
+
if (read.kind === "found") {
|
|
123
|
+
expect(read.metadata).toEqual({
|
|
124
|
+
metadataUrl: "https://login.vendor.test/.well-known/oauth-authorization-server/tenant",
|
|
125
|
+
issuer: "https://login.vendor.test",
|
|
126
|
+
authorizationEndpoint: "https://login.vendor.test/authorize",
|
|
127
|
+
tokenEndpoint: "https://login.vendor.test/token",
|
|
128
|
+
registrationEndpoint: "https://login.vendor.test/register",
|
|
129
|
+
scopesSupported: ["read"],
|
|
130
|
+
codeChallengeMethodsSupported: ["S256"],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
expect(requested).toEqual(["https://login.vendor.test/.well-known/oauth-authorization-server/tenant"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("falls back to OpenID's document and reads an absent registration_endpoint as empty", async () => {
|
|
137
|
+
const { fetchImpl, requested } = serving({
|
|
138
|
+
"https://login.vendor.test/.well-known/openid-configuration": { ...ISSUER_DOCUMENT, registration_endpoint: undefined, scopes_supported: undefined },
|
|
139
|
+
});
|
|
140
|
+
const read = await readAuthorizationServerMetadata("https://login.vendor.test", { ...deps, fetchImpl });
|
|
141
|
+
expect(read.kind).toBe("found");
|
|
142
|
+
if (read.kind === "found") {
|
|
143
|
+
expect(read.metadata.registrationEndpoint).toBe("");
|
|
144
|
+
expect(read.metadata.scopesSupported).toEqual([]);
|
|
145
|
+
expect(read.metadata.metadataUrl).toBe("https://login.vendor.test/.well-known/openid-configuration");
|
|
146
|
+
}
|
|
147
|
+
expect(requested).toEqual(["https://login.vendor.test/.well-known/oauth-authorization-server", "https://login.vendor.test/.well-known/openid-configuration"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("skips a document that lacks either endpoint and reports not-found with every attempt", async () => {
|
|
151
|
+
const { fetchImpl } = serving({
|
|
152
|
+
"https://login.vendor.test/.well-known/oauth-authorization-server": { issuer: "x", authorization_endpoint: "https://login.vendor.test/authorize" },
|
|
153
|
+
"https://login.vendor.test/.well-known/openid-configuration": { status: 503 },
|
|
154
|
+
});
|
|
155
|
+
const read = await readAuthorizationServerMetadata("https://login.vendor.test", { ...deps, fetchImpl });
|
|
156
|
+
expect(read).toEqual({
|
|
157
|
+
kind: "not-found",
|
|
158
|
+
attempts: [
|
|
159
|
+
{ url: "https://login.vendor.test/.well-known/oauth-authorization-server", status: 200, missing: "token_endpoint" },
|
|
160
|
+
{ url: "https://login.vendor.test/.well-known/openid-configuration", status: 503 },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("reports not-found for an unparseable issuer without dialling", async () => {
|
|
166
|
+
const { fetchImpl, requested } = serving({});
|
|
167
|
+
const read = await readAuthorizationServerMetadata("::::", { ...deps, fetchImpl });
|
|
168
|
+
expect(read).toEqual({ kind: "not-found", attempts: [] });
|
|
169
|
+
expect(requested).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the one-request probe: the complete `initialize` body (the shape
|
|
3
|
+
* several hosted servers demand before they authenticate), the caller's
|
|
4
|
+
* headers and name carried, the closed outcome vocabulary, the body
|
|
5
|
+
* released unread, and a failing fetch answered as `unreachable`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import type { OutboundFetch } from "../egress/fetch.js";
|
|
10
|
+
import { probeEndpointAuth } from "../mcp-oauth/probe.js";
|
|
11
|
+
import { MCP_PROTOCOL_VERSION } from "../mcp-oauth/request.js";
|
|
12
|
+
|
|
13
|
+
const deps = { timeoutMs: 3_000, clientName: "stigmer-test" };
|
|
14
|
+
|
|
15
|
+
function answering(status: number, headers: Record<string, string> = {}, body: string | null = null): { fetchImpl: OutboundFetch; calls: { url: string; init: RequestInit | undefined }[] } {
|
|
16
|
+
const calls: { url: string; init: RequestInit | undefined }[] = [];
|
|
17
|
+
const fetchImpl: OutboundFetch = async (url, init) => {
|
|
18
|
+
calls.push({ url: String(url), init });
|
|
19
|
+
return new Response(body, { status, headers });
|
|
20
|
+
};
|
|
21
|
+
return { fetchImpl, calls };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("probeEndpointAuth", () => {
|
|
25
|
+
it("sends one complete initialize, naming the caller, with the endpoint's headers layered on", async () => {
|
|
26
|
+
const { fetchImpl, calls } = answering(200, {}, "{}");
|
|
27
|
+
await probeEndpointAuth("https://mcp.vendor.test/mcp", { "X-Vendor": "v" }, { ...deps, fetchImpl });
|
|
28
|
+
expect(calls).toHaveLength(1);
|
|
29
|
+
const init = calls[0]?.init;
|
|
30
|
+
expect(init?.method).toBe("POST");
|
|
31
|
+
const headers = init?.headers as Record<string, string>;
|
|
32
|
+
expect(headers["Accept"]).toBe("application/json, text/event-stream");
|
|
33
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
34
|
+
expect(headers["MCP-Protocol-Version"]).toBe(MCP_PROTOCOL_VERSION);
|
|
35
|
+
expect(headers["X-Vendor"]).toBe("v");
|
|
36
|
+
const body = JSON.parse(String(init?.body)) as { method: string; params: { protocolVersion: string; capabilities: object; clientInfo: { name: string } } };
|
|
37
|
+
expect(body.method).toBe("initialize");
|
|
38
|
+
expect(body.params.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
|
|
39
|
+
expect(body.params.capabilities).toEqual({});
|
|
40
|
+
expect(body.params.clientInfo.name).toBe("stigmer-test");
|
|
41
|
+
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reads a 401 OAuth challenge as oauth with its resource_metadata pointer", async () => {
|
|
45
|
+
const { fetchImpl } = answering(401, { "www-authenticate": 'Bearer realm="OAuth", resource_metadata="https://mcp.vendor.test/.well-known/oauth-protected-resource"' });
|
|
46
|
+
await expect(probeEndpointAuth("https://mcp.vendor.test/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({
|
|
47
|
+
kind: "oauth",
|
|
48
|
+
challenge: 'Bearer realm="OAuth", resource_metadata="https://mcp.vendor.test/.well-known/oauth-protected-resource"',
|
|
49
|
+
resourceMetadataUrl: "https://mcp.vendor.test/.well-known/oauth-protected-resource",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reads a 401 without a pointer as oauth when the realm says OAuth", async () => {
|
|
54
|
+
const { fetchImpl } = answering(401, { "www-authenticate": 'Bearer realm="OAuth"' });
|
|
55
|
+
await expect(probeEndpointAuth("https://mcp.vendor.test/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({
|
|
56
|
+
kind: "oauth",
|
|
57
|
+
challenge: 'Bearer realm="OAuth"',
|
|
58
|
+
resourceMetadataUrl: undefined,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads a plain Bearer 401 as challenge-not-oauth", async () => {
|
|
63
|
+
const { fetchImpl } = answering(401, { "www-authenticate": 'Bearer error="invalid_token"' });
|
|
64
|
+
await expect(probeEndpointAuth("https://api.vendor.test/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({
|
|
65
|
+
kind: "challenge-not-oauth",
|
|
66
|
+
challenge: 'Bearer error="invalid_token"',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reads a 401 without a WWW-Authenticate header as challenge-not-oauth with an empty challenge", async () => {
|
|
71
|
+
const { fetchImpl } = answering(401);
|
|
72
|
+
await expect(probeEndpointAuth("https://api.vendor.test/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({ kind: "challenge-not-oauth", challenge: "" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reads a 2xx as accepted whatever the body says, and releases the body", async () => {
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
78
|
+
cancel() {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const fetchImpl: OutboundFetch = async () => new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
83
|
+
await expect(probeEndpointAuth("https://open.vendor.test/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({ kind: "accepted", status: 200 });
|
|
84
|
+
expect(cancelled).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reads any other status as http-other", async () => {
|
|
88
|
+
const { fetchImpl } = answering(405);
|
|
89
|
+
await expect(probeEndpointAuth("https://api.vendor.test/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({ kind: "http-other", status: 405 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("never throws: a failing fetch is unreachable, naming the error", async () => {
|
|
93
|
+
const fetchImpl: OutboundFetch = async () => {
|
|
94
|
+
throw new Error("connect ECONNREFUSED");
|
|
95
|
+
};
|
|
96
|
+
await expect(probeEndpointAuth("http://127.0.0.1:9/mcp", undefined, { ...deps, fetchImpl })).resolves.toEqual({ kind: "unreachable", error: "Error: connect ECONNREFUSED" });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address classification for outbound egress: which IP addresses a Stigmer
|
|
3
|
+
* process refuses to dial, under which posture, and why.
|
|
4
|
+
*
|
|
5
|
+
* This is the one home of the rule. It began as the runner's `web_fetch`
|
|
6
|
+
* SSRF boundary (backend/services/runner/src/tools/url-guard.ts, which now
|
|
7
|
+
* delegates here) and is shared because the control plane dials
|
|
8
|
+
* user-supplied URLs too: an MCP endpoint at save time and its login server
|
|
9
|
+
* on Sign in. A rule with two copies drifts; a copy that drifted on the
|
|
10
|
+
* control plane would be the copy guarding the wider network.
|
|
11
|
+
*
|
|
12
|
+
* Two postures, because locality differs, not trust:
|
|
13
|
+
*
|
|
14
|
+
* - "strict" (managed cloud processes): loopback, RFC 1918 private,
|
|
15
|
+
* link-local, unspecified, and their IPv6 equivalents are all refused.
|
|
16
|
+
* - "relaxed" (self-hosted and local processes): the machine belongs to
|
|
17
|
+
* the user, and a server beside the process (http://localhost:3000/mcp,
|
|
18
|
+
* the proto's own example) is the normal case. Only the link-local range,
|
|
19
|
+
* which carries the cloud metadata endpoint 169.254.169.254, stays
|
|
20
|
+
* refused, as costless defence in depth.
|
|
21
|
+
*
|
|
22
|
+
* Who chooses the posture is the caller's business: the runner keys it on
|
|
23
|
+
* its mode with an operator override; the control plane takes it from an
|
|
24
|
+
* edition driver point. This module knows addresses and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* IPv4-mapped IPv6 (::ffff:a.b.c.d in dotted or hex form) is classified by
|
|
27
|
+
* the embedded IPv4, so the mapping cannot smuggle a refused address past
|
|
28
|
+
* the check. Anything that is not a well-formed address fails closed.
|
|
29
|
+
*
|
|
30
|
+
* Proven by __tests__/address.test.ts, the runner's classification cases
|
|
31
|
+
* moved here byte for byte.
|
|
32
|
+
*/
|
|
33
|
+
import { isIP } from "node:net";
|
|
34
|
+
|
|
35
|
+
/** The two egress postures; the caller derives one from where it runs. */
|
|
36
|
+
export type EgressPosture = "strict" | "relaxed";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An egress policy: the synchronous address judgement a process dials
|
|
40
|
+
* under. `blockedReason` answers for every string (an unparseable one is
|
|
41
|
+
* refused as "unrecognized"), never throws, and does no I/O. A policy that
|
|
42
|
+
* would need a network or a hostname to answer is a different seam.
|
|
43
|
+
*/
|
|
44
|
+
export interface EgressPolicy {
|
|
45
|
+
/** The posture's name, for logs and error copy. */
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/** A human-readable range name when the address is refused, null when allowed. */
|
|
48
|
+
blockedReason(address: string): string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The policy for a posture: the two shapes every consumer composes. */
|
|
52
|
+
export function egressPolicyForPosture(posture: EgressPosture): EgressPolicy {
|
|
53
|
+
return {
|
|
54
|
+
name: posture,
|
|
55
|
+
blockedReason: (address) => blockedReason(address, posture),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify an IP address against the posture's refused ranges.
|
|
61
|
+
*
|
|
62
|
+
* @returns a human-readable range name when refused, or null when allowed.
|
|
63
|
+
*/
|
|
64
|
+
export function blockedReason(address: string, posture: EgressPosture): string | null {
|
|
65
|
+
const family = isIP(address);
|
|
66
|
+
|
|
67
|
+
if (family === 4) {
|
|
68
|
+
return blockedReasonV4(address, posture);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (family === 6) {
|
|
72
|
+
const groups = expandV6(address);
|
|
73
|
+
if (!groups) return "unrecognized";
|
|
74
|
+
|
|
75
|
+
const embedded = extractMappedV4(groups);
|
|
76
|
+
if (embedded) {
|
|
77
|
+
return blockedReasonV4(embedded, posture);
|
|
78
|
+
}
|
|
79
|
+
return blockedReasonV6(groups, posture);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Unparseable: fail closed; only real addresses get sockets.
|
|
83
|
+
return "unrecognized";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function blockedReasonV4(address: string, posture: EgressPosture): string | null {
|
|
87
|
+
const octets = address.split(".").map(Number);
|
|
88
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
|
|
89
|
+
return "unrecognized";
|
|
90
|
+
}
|
|
91
|
+
const a = octets[0];
|
|
92
|
+
const b = octets[1];
|
|
93
|
+
|
|
94
|
+
// Link-local (169.254.0.0/16) hosts the cloud metadata service at
|
|
95
|
+
// 169.254.169.254: refused under BOTH postures.
|
|
96
|
+
if (a === 169 && b === 254) return "link-local (cloud metadata)";
|
|
97
|
+
|
|
98
|
+
if (posture === "relaxed") return null;
|
|
99
|
+
|
|
100
|
+
if (a === 127) return "loopback";
|
|
101
|
+
if (a === 0) return "unspecified";
|
|
102
|
+
if (a === 10) return "private (RFC 1918)";
|
|
103
|
+
if (a === 172 && b !== undefined && b >= 16 && b <= 31) return "private (RFC 1918)";
|
|
104
|
+
if (a === 192 && b === 168) return "private (RFC 1918)";
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function blockedReasonV6(groups: readonly number[], posture: EgressPosture): string | null {
|
|
110
|
+
const first = groups[0] ?? 0;
|
|
111
|
+
// fe80::/10, the IPv6 link-local range, the v6 sibling of the metadata
|
|
112
|
+
// range: refused under BOTH postures for symmetry with v4.
|
|
113
|
+
if ((first & 0xffc0) === 0xfe80) return "link-local (cloud metadata)";
|
|
114
|
+
|
|
115
|
+
if (posture === "relaxed") return null;
|
|
116
|
+
|
|
117
|
+
const allZero = groups.every((g) => g === 0);
|
|
118
|
+
if (allZero) return "unspecified";
|
|
119
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return "loopback";
|
|
120
|
+
// fc00::/7: unique local (private) addresses.
|
|
121
|
+
if ((first & 0xfe00) === 0xfc00) return "private (unique local)";
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Expand an IPv6 address (already validated by isIP) into its 8 groups.
|
|
128
|
+
* Handles `::` compression, a trailing dotted-IPv4 tail, and zone suffixes.
|
|
129
|
+
*/
|
|
130
|
+
function expandV6(address: string): number[] | null {
|
|
131
|
+
let text = address.toLowerCase().split("%")[0] ?? "";
|
|
132
|
+
|
|
133
|
+
// Convert a dotted-IPv4 tail (for example ::ffff:127.0.0.1) into two hex groups.
|
|
134
|
+
const v4Tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
135
|
+
const tail4 = v4Tail?.[1];
|
|
136
|
+
if (tail4 !== undefined) {
|
|
137
|
+
const octets = tail4.split(".").map(Number);
|
|
138
|
+
if (octets.length !== 4 || octets.some((o) => o > 255)) return null;
|
|
139
|
+
const [o0 = 0, o1 = 0, o2 = 0, o3 = 0] = octets;
|
|
140
|
+
const hex = `${((o0 << 8) | o1).toString(16)}:${((o2 << 8) | o3).toString(16)}`;
|
|
141
|
+
text = text.slice(0, -tail4.length) + hex;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const halves = text.split("::");
|
|
145
|
+
if (halves.length > 2) return null;
|
|
146
|
+
|
|
147
|
+
const parseHalf = (half: string): number[] => (half === "" ? [] : half.split(":").map((g) => parseInt(g, 16)));
|
|
148
|
+
|
|
149
|
+
const head = parseHalf(halves[0] ?? "");
|
|
150
|
+
const tail = halves.length === 2 ? parseHalf(halves[1] ?? "") : [];
|
|
151
|
+
const fill = 8 - head.length - tail.length;
|
|
152
|
+
if (halves.length === 2 ? fill < 0 : head.length !== 8) return null;
|
|
153
|
+
|
|
154
|
+
const groups = [...head, ...(halves.length === 2 ? Array<number>(fill).fill(0) : []), ...tail];
|
|
155
|
+
if (groups.length !== 8 || groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff)) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return groups;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Extract the IPv4 payload from IPv4-mapped groups (::ffff:0:0/96), if any. */
|
|
162
|
+
function extractMappedV4(groups: readonly number[]): string | null {
|
|
163
|
+
const isMapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
|
|
164
|
+
if (!isMapped) return null;
|
|
165
|
+
const hi = groups[6] ?? 0;
|
|
166
|
+
const lo = groups[7] ?? 0;
|
|
167
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
168
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The egress check: may this process dial this URL under this policy?
|
|
3
|
+
*
|
|
4
|
+
* The answer is a value, not a throw, so a caller can render its own
|
|
5
|
+
* sentence (the runner tells a model "this runner does not fetch from", the
|
|
6
|
+
* control plane tells a user what the endpoint resolved to) while the
|
|
7
|
+
* judgement itself has one home. `EgressError` wraps a refusal for the
|
|
8
|
+
* callers that want a throw (the guarded fetch).
|
|
9
|
+
*
|
|
10
|
+
* The check resolves the hostname and judges EVERY address it resolves to:
|
|
11
|
+
* a name with one public and one private record must be refused, or the
|
|
12
|
+
* private record becomes the bypass. Literal IP hosts are judged as they
|
|
13
|
+
* are, without a lookup. Resolution is injected (`LookupFn`) so tests never
|
|
14
|
+
* touch DNS, and it races the caller's `signal`: `dns.lookup` has no
|
|
15
|
+
* timeout of its own, and a save that waits on a hanging resolver would be
|
|
16
|
+
* a save that hangs (the control plane's probe runs under one deadline
|
|
17
|
+
* that covers resolution, for exactly this reason). An aborted signal
|
|
18
|
+
* propagates as the signal's reason, the way `fetch` itself behaves.
|
|
19
|
+
*
|
|
20
|
+
* Accepted limitation, inherited from the runner's guard: a DNS-rebinding
|
|
21
|
+
* window remains between our lookup and the socket connect. Node's fetch
|
|
22
|
+
* (undici) offers no lookup pinning without replacing the dispatcher, and
|
|
23
|
+
* the strict posture's range refusals make the rebinding payoff (an
|
|
24
|
+
* internal address) unreachable anyway.
|
|
25
|
+
*
|
|
26
|
+
* Proven by __tests__/check.test.ts.
|
|
27
|
+
*/
|
|
28
|
+
import { isIP } from "node:net";
|
|
29
|
+
|
|
30
|
+
import type { EgressPolicy } from "./address.js";
|
|
31
|
+
|
|
32
|
+
/** Resolve a hostname to every address it has; rejects when it has none. */
|
|
33
|
+
export type LookupFn = (hostname: string) => Promise<readonly string[]>;
|
|
34
|
+
|
|
35
|
+
/** Why a URL was refused; one shape per cause, each carrying what a sentence needs. */
|
|
36
|
+
export type EgressRefusal =
|
|
37
|
+
| { readonly kind: "invalid-url"; readonly url: string }
|
|
38
|
+
| { readonly kind: "unsupported-scheme"; readonly url: URL; readonly scheme: string }
|
|
39
|
+
| { readonly kind: "unresolvable"; readonly url: URL; readonly hostname: string }
|
|
40
|
+
| {
|
|
41
|
+
readonly kind: "blocked";
|
|
42
|
+
readonly url: URL;
|
|
43
|
+
readonly hostname: string;
|
|
44
|
+
readonly address: string;
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
readonly policy: string;
|
|
47
|
+
}
|
|
48
|
+
| { readonly kind: "too-many-redirects"; readonly url: URL; readonly hops: number };
|
|
49
|
+
|
|
50
|
+
export type EgressCheck =
|
|
51
|
+
| { readonly ok: true; readonly url: URL; readonly addresses: readonly string[] }
|
|
52
|
+
| { readonly ok: false; readonly refusal: EgressRefusal };
|
|
53
|
+
|
|
54
|
+
export interface EgressCheckOptions {
|
|
55
|
+
readonly lookup: LookupFn;
|
|
56
|
+
/** Bounds the resolution; an abort propagates as the signal's reason. */
|
|
57
|
+
readonly signal?: AbortSignal | null | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One sentence per refusal, safe to show a user or a model. */
|
|
61
|
+
export function describeRefusal(refusal: EgressRefusal): string {
|
|
62
|
+
switch (refusal.kind) {
|
|
63
|
+
case "invalid-url":
|
|
64
|
+
return `Invalid URL: ${refusal.url}`;
|
|
65
|
+
case "unsupported-scheme":
|
|
66
|
+
return `Unsupported URL scheme "${refusal.scheme}": only http and https are allowed.`;
|
|
67
|
+
case "unresolvable":
|
|
68
|
+
return `Could not resolve hostname: ${refusal.hostname}`;
|
|
69
|
+
case "blocked":
|
|
70
|
+
return `Refusing to reach ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address the ${refusal.policy} egress policy does not dial.`;
|
|
71
|
+
case "too-many-redirects":
|
|
72
|
+
return `Refusing to follow more than ${refusal.hops} redirects from ${refusal.url.href}.`;
|
|
73
|
+
default: {
|
|
74
|
+
const exhaustive: never = refusal;
|
|
75
|
+
return exhaustive;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A refusal as a throwable, for callers that want one. */
|
|
81
|
+
export class EgressError extends Error {
|
|
82
|
+
constructor(readonly refusal: EgressRefusal) {
|
|
83
|
+
super(describeRefusal(refusal));
|
|
84
|
+
this.name = "EgressError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Judge a URL under a policy: scheme, then every resolved address. */
|
|
89
|
+
export async function checkEgress(rawUrl: string | URL, policy: EgressPolicy, options: EgressCheckOptions): Promise<EgressCheck> {
|
|
90
|
+
let url: URL;
|
|
91
|
+
try {
|
|
92
|
+
url = new URL(rawUrl);
|
|
93
|
+
} catch {
|
|
94
|
+
return { ok: false, refusal: { kind: "invalid-url", url: String(rawUrl) } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
98
|
+
return { ok: false, refusal: { kind: "unsupported-scheme", url, scheme: url.protocol.replace(/:$/, "") } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const addresses = await resolveAddresses(url.hostname, options);
|
|
102
|
+
if (addresses === undefined) {
|
|
103
|
+
return { ok: false, refusal: { kind: "unresolvable", url, hostname: url.hostname } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const address of addresses) {
|
|
107
|
+
const reason = policy.blockedReason(address);
|
|
108
|
+
if (reason !== null) {
|
|
109
|
+
return { ok: false, refusal: { kind: "blocked", url, hostname: url.hostname, address, reason, policy: policy.name } };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { ok: true, url, addresses };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every address a hostname has. Literal IPs pass through (a bracketed IPv6
|
|
118
|
+
* host arrives from URL.hostname still bracketed). `undefined` when the name
|
|
119
|
+
* does not resolve; an aborted signal throws its reason.
|
|
120
|
+
*/
|
|
121
|
+
async function resolveAddresses(hostname: string, options: EgressCheckOptions): Promise<readonly string[] | undefined> {
|
|
122
|
+
const literal = hostname.replace(/^\[|\]$/g, "");
|
|
123
|
+
if (isIP(literal) !== 0) {
|
|
124
|
+
return [literal];
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const records = await raceSignal(options.lookup(hostname), options.signal);
|
|
128
|
+
return records.length === 0 ? undefined : records;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (options.signal?.aborted === true) throw error;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function raceSignal<T>(promise: Promise<T>, signal: AbortSignal | null | undefined): Promise<T> {
|
|
136
|
+
if (signal === null || signal === undefined) return promise;
|
|
137
|
+
if (signal.aborted) {
|
|
138
|
+
// The resolver was already asked; its eventual answer is nobody's now.
|
|
139
|
+
promise.catch(() => undefined);
|
|
140
|
+
return Promise.reject(abortReason(signal));
|
|
141
|
+
}
|
|
142
|
+
return new Promise<T>((resolve, reject) => {
|
|
143
|
+
const onAbort = (): void => {
|
|
144
|
+
promise.catch(() => undefined);
|
|
145
|
+
reject(abortReason(signal));
|
|
146
|
+
};
|
|
147
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
promise.then(
|
|
149
|
+
(value) => {
|
|
150
|
+
signal.removeEventListener("abort", onAbort);
|
|
151
|
+
resolve(value);
|
|
152
|
+
},
|
|
153
|
+
(error: unknown) => {
|
|
154
|
+
signal.removeEventListener("abort", onAbort);
|
|
155
|
+
reject(error);
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
162
|
+
return signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
163
|
+
}
|