@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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The requests Stigmer sends an MCP endpoint it does not yet hold a
|
|
3
|
+
* credential for: one complete `initialize`, and the `tools/list` the
|
|
4
|
+
* catalogue audit follows an open handshake with.
|
|
5
|
+
*
|
|
6
|
+
* Complete, not minimal. A bare `initialize` with empty params never
|
|
7
|
+
* reaches several hosted servers' authentication: Google's Gmail, Calendar,
|
|
8
|
+
* Drive and BigQuery endpoints, GoDaddy's and Shopify's validate the
|
|
9
|
+
* request first and answer HTTP 200 with a JSON-RPC error ("Missing
|
|
10
|
+
* protocol version for Initialize"), so a probe that sent the bare request
|
|
11
|
+
* called seven OAuth servers open (measured 2026-09-19 against the three
|
|
12
|
+
* vendor catalogues; stigmer #1188 records the runner's copy of the
|
|
13
|
+
* mistake). The request therefore carries the protocol version, empty
|
|
14
|
+
* capabilities and a `clientInfo` that names the caller, so a vendor's
|
|
15
|
+
* logs say which Stigmer process asked.
|
|
16
|
+
*
|
|
17
|
+
* `MCP_PROTOCOL_VERSION` is the revision the runner's client speaks. It is
|
|
18
|
+
* a constant here rather than an import from the MCP SDK so this library
|
|
19
|
+
* stays dependency-free; the runner pins the agreement in its own tests.
|
|
20
|
+
*/
|
|
21
|
+
/** The MCP protocol revision the handshake names. */
|
|
22
|
+
export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
23
|
+
/** A JSON-RPC POST, ready to hand to `fetch` after adding a signal. */
|
|
24
|
+
export interface JsonRpcRequestInit {
|
|
25
|
+
readonly method: "POST";
|
|
26
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
27
|
+
readonly body: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A complete `initialize`. `clientName` names the process asking
|
|
31
|
+
* (`stigmer-server`, `stigmer-runner`, `stigmer-catalogue-audit`); `headers`
|
|
32
|
+
* are the endpoint's declared headers with any credential the caller holds.
|
|
33
|
+
*/
|
|
34
|
+
export declare function initializeRequest(clientName: string, headers?: Readonly<Record<string, string>>): JsonRpcRequestInit;
|
|
35
|
+
/** The header a stateful server hands back on `initialize` and expects on every later request. */
|
|
36
|
+
export declare const MCP_SESSION_HEADER = "mcp-session-id";
|
|
37
|
+
/**
|
|
38
|
+
* The anonymous `tools/list` that follows an open handshake, carrying the
|
|
39
|
+
* session the server issued when it issued one. Used by the audit, which
|
|
40
|
+
* measures what a user could reach without signing in; the control plane's
|
|
41
|
+
* save-time probe stops at `initialize`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toolsListRequest(headers?: Readonly<Record<string, string>>, sessionId?: string): JsonRpcRequestInit;
|
|
44
|
+
//# sourceMappingURL=request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../../src/mcp-oauth/request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,qDAAqD;AACrD,eAAO,MAAM,oBAAoB,eAAe,CAAC;AAEjD,uEAAuE;AACvE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAYD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,kBAAkB,CAepH;AAED,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,mBAAmB,CAAC;AAEnD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,kBAAkB,CAQnH"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The requests Stigmer sends an MCP endpoint it does not yet hold a
|
|
3
|
+
* credential for: one complete `initialize`, and the `tools/list` the
|
|
4
|
+
* catalogue audit follows an open handshake with.
|
|
5
|
+
*
|
|
6
|
+
* Complete, not minimal. A bare `initialize` with empty params never
|
|
7
|
+
* reaches several hosted servers' authentication: Google's Gmail, Calendar,
|
|
8
|
+
* Drive and BigQuery endpoints, GoDaddy's and Shopify's validate the
|
|
9
|
+
* request first and answer HTTP 200 with a JSON-RPC error ("Missing
|
|
10
|
+
* protocol version for Initialize"), so a probe that sent the bare request
|
|
11
|
+
* called seven OAuth servers open (measured 2026-09-19 against the three
|
|
12
|
+
* vendor catalogues; stigmer #1188 records the runner's copy of the
|
|
13
|
+
* mistake). The request therefore carries the protocol version, empty
|
|
14
|
+
* capabilities and a `clientInfo` that names the caller, so a vendor's
|
|
15
|
+
* logs say which Stigmer process asked.
|
|
16
|
+
*
|
|
17
|
+
* `MCP_PROTOCOL_VERSION` is the revision the runner's client speaks. It is
|
|
18
|
+
* a constant here rather than an import from the MCP SDK so this library
|
|
19
|
+
* stays dependency-free; the runner pins the agreement in its own tests.
|
|
20
|
+
*/
|
|
21
|
+
/** The MCP protocol revision the handshake names. */
|
|
22
|
+
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
23
|
+
/** The headers every request carries; the caller's headers are layered on top. */
|
|
24
|
+
function baseHeaders(headers) {
|
|
25
|
+
return {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
Accept: "application/json, text/event-stream",
|
|
28
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
29
|
+
...(headers ?? {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A complete `initialize`. `clientName` names the process asking
|
|
34
|
+
* (`stigmer-server`, `stigmer-runner`, `stigmer-catalogue-audit`); `headers`
|
|
35
|
+
* are the endpoint's declared headers with any credential the caller holds.
|
|
36
|
+
*/
|
|
37
|
+
export function initializeRequest(clientName, headers) {
|
|
38
|
+
return {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: baseHeaders(headers),
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
jsonrpc: "2.0",
|
|
43
|
+
id: 1,
|
|
44
|
+
method: "initialize",
|
|
45
|
+
params: {
|
|
46
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
47
|
+
capabilities: {},
|
|
48
|
+
clientInfo: { name: clientName, version: "0" },
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The header a stateful server hands back on `initialize` and expects on every later request. */
|
|
54
|
+
export const MCP_SESSION_HEADER = "mcp-session-id";
|
|
55
|
+
/**
|
|
56
|
+
* The anonymous `tools/list` that follows an open handshake, carrying the
|
|
57
|
+
* session the server issued when it issued one. Used by the audit, which
|
|
58
|
+
* measures what a user could reach without signing in; the control plane's
|
|
59
|
+
* save-time probe stops at `initialize`.
|
|
60
|
+
*/
|
|
61
|
+
export function toolsListRequest(headers, sessionId) {
|
|
62
|
+
const merged = baseHeaders(headers);
|
|
63
|
+
if (sessionId !== undefined)
|
|
64
|
+
merged[MCP_SESSION_HEADER] = sessionId;
|
|
65
|
+
return {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: merged,
|
|
68
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=request.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request.js","sourceRoot":"","sources":["../../src/mcp-oauth/request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,qDAAqD;AACrD,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC;AASjD,kFAAkF;AAClF,SAAS,WAAW,CAAC,OAAqD;IACxE,OAAO;QACL,cAAc,EAAE,kBAAkB;QAClC,MAAM,EAAE,qCAAqC;QAC7C,sBAAsB,EAAE,oBAAoB;QAC5C,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;KACnB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,UAAkB,EAAE,OAA0C;IAC9F,OAAO;QACL,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,WAAW,CAAC,OAAO,CAAC;QAC7B,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,OAAO,EAAE,KAAK;YACd,EAAE,EAAE,CAAC;YACL,MAAM,EAAE,YAAY;YACpB,MAAM,EAAE;gBACN,eAAe,EAAE,oBAAoB;gBACrC,YAAY,EAAE,EAAE;gBAChB,UAAU,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE;aAC/C;SACF,CAAC;KACH,CAAC;AACJ,CAAC;AAED,kGAAkG;AAClG,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAA0C,EAAE,SAAkB;IAC7F,MAAM,MAAM,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;IACpC,IAAI,SAAS,KAAK,SAAS;QAAE,MAAM,CAAC,kBAAkB,CAAC,GAAG,SAAS,CAAC;IACpE,OAAO;QACL,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,MAAM;QACf,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;KAClF,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stigmer/outbound",
|
|
3
|
+
"version": "3.18.1-dev.20260919070736",
|
|
4
|
+
"description": "The rules Stigmer's TypeScript processes dial a user-supplied endpoint by, shared by the control plane, the runner and the catalogue audit: the egress address policy and its guarded fetch, and the way an MCP endpoint's authentication is read (the OAuth-challenge rule, the complete initialize handshake, the RFC 9728 / RFC 8414 / OpenID resolution of its login server)",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/stigmer/stigmer.git",
|
|
11
|
+
"directory": "backend/libs/ts/outbound"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.13.0 || >=23.4.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"stigmer",
|
|
18
|
+
"egress",
|
|
19
|
+
"ssrf",
|
|
20
|
+
"mcp",
|
|
21
|
+
"oauth"
|
|
22
|
+
],
|
|
23
|
+
"main": "./index.js",
|
|
24
|
+
"types": "./index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"import": "./index.js",
|
|
29
|
+
"default": "./index.js"
|
|
30
|
+
},
|
|
31
|
+
"./egress": {
|
|
32
|
+
"types": "./egress/index.d.ts",
|
|
33
|
+
"import": "./egress/index.js",
|
|
34
|
+
"default": "./egress/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./mcp-oauth": {
|
|
37
|
+
"types": "./mcp-oauth/index.d.ts",
|
|
38
|
+
"import": "./mcp-oauth/index.js",
|
|
39
|
+
"default": "./mcp-oauth/index.js"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the address classification under both postures: the runner's cases,
|
|
3
|
+
* moved here byte for byte when the rule became shared, plus the policy
|
|
4
|
+
* shape a consumer composes.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { blockedReason, egressPolicyForPosture } from "../egress/address.js";
|
|
9
|
+
|
|
10
|
+
describe("blockedReason, strict posture", () => {
|
|
11
|
+
it.each([
|
|
12
|
+
["127.0.0.1", "loopback"],
|
|
13
|
+
["127.255.255.255", "loopback"],
|
|
14
|
+
["0.0.0.0", "unspecified"],
|
|
15
|
+
["10.0.0.1", "private (RFC 1918)"],
|
|
16
|
+
["172.16.0.1", "private (RFC 1918)"],
|
|
17
|
+
["172.31.255.255", "private (RFC 1918)"],
|
|
18
|
+
["192.168.1.1", "private (RFC 1918)"],
|
|
19
|
+
["169.254.169.254", "link-local (cloud metadata)"],
|
|
20
|
+
["169.254.0.1", "link-local (cloud metadata)"],
|
|
21
|
+
])("blocks %s as %s", (address, reason) => {
|
|
22
|
+
expect(blockedReason(address, "strict")).toBe(reason);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each(["8.8.8.8", "104.16.0.1", "172.15.0.1", "172.32.0.1", "192.169.0.1"])("allows public IPv4 %s", (address) => {
|
|
26
|
+
expect(blockedReason(address, "strict")).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
["::1", "loopback"],
|
|
31
|
+
["::", "unspecified"],
|
|
32
|
+
["fc00::1", "private (unique local)"],
|
|
33
|
+
["fd12:3456::1", "private (unique local)"],
|
|
34
|
+
["fe80::1", "link-local (cloud metadata)"],
|
|
35
|
+
["febf::1", "link-local (cloud metadata)"],
|
|
36
|
+
])("blocks IPv6 %s as %s", (address, reason) => {
|
|
37
|
+
expect(blockedReason(address, "strict")).toBe(reason);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("allows public IPv6", () => {
|
|
41
|
+
expect(blockedReason("2606:4700::6810:1", "strict")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("blocks IPv4-mapped IPv6 carrying a blocked v4 (dotted form)", () => {
|
|
45
|
+
expect(blockedReason("::ffff:127.0.0.1", "strict")).toBe("loopback");
|
|
46
|
+
expect(blockedReason("::ffff:10.0.0.1", "strict")).toBe("private (RFC 1918)");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("blocks IPv4-mapped IPv6 carrying a blocked v4 (hex form)", () => {
|
|
50
|
+
expect(blockedReason("::ffff:7f00:1", "strict")).toBe("loopback");
|
|
51
|
+
expect(blockedReason("::ffff:a9fe:a9fe", "strict")).toBe("link-local (cloud metadata)");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("allows IPv4-mapped IPv6 carrying a public v4", () => {
|
|
55
|
+
expect(blockedReason("::ffff:8.8.8.8", "strict")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores a zone suffix when classifying", () => {
|
|
59
|
+
expect(blockedReason("fe80::1%eth0", "strict")).toBe("link-local (cloud metadata)");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("fails closed on garbage", () => {
|
|
63
|
+
expect(blockedReason("not-an-ip", "strict")).toBe("unrecognized");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("blockedReason, relaxed posture", () => {
|
|
68
|
+
it.each(["127.0.0.1", "10.0.0.1", "192.168.1.1", "::1", "fd00::1"])("allows private/loopback %s (the machine belongs to the user)", (address) => {
|
|
69
|
+
expect(blockedReason(address, "relaxed")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("still blocks the cloud metadata range", () => {
|
|
73
|
+
expect(blockedReason("169.254.169.254", "relaxed")).toBe("link-local (cloud metadata)");
|
|
74
|
+
expect(blockedReason("fe80::1", "relaxed")).toBe("link-local (cloud metadata)");
|
|
75
|
+
expect(blockedReason("::ffff:169.254.169.254", "relaxed")).toBe("link-local (cloud metadata)");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("egressPolicyForPosture", () => {
|
|
80
|
+
it("names the posture and applies its classification", () => {
|
|
81
|
+
const strict = egressPolicyForPosture("strict");
|
|
82
|
+
const relaxed = egressPolicyForPosture("relaxed");
|
|
83
|
+
expect(strict.name).toBe("strict");
|
|
84
|
+
expect(relaxed.name).toBe("relaxed");
|
|
85
|
+
expect(strict.blockedReason("10.0.0.1")).toBe("private (RFC 1918)");
|
|
86
|
+
expect(relaxed.blockedReason("10.0.0.1")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the OAuth-challenge rule: the runner's cases, moved here when the
|
|
3
|
+
* rule became shared, and the `resource_metadata` pointer read.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { isOAuthChallenge, parseResourceMetadataUrl } from "../mcp-oauth/challenge.js";
|
|
8
|
+
|
|
9
|
+
describe("isOAuthChallenge", () => {
|
|
10
|
+
it("matches the MCP auth spec Bearer + OAuth realm challenge", () => {
|
|
11
|
+
expect(isOAuthChallenge('Bearer realm="OAuth", resource_metadata="https://mcp.vendor.test/.well-known/oauth-protected-resource"')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("matches a Bearer challenge that only advertises resource_metadata", () => {
|
|
15
|
+
expect(isOAuthChallenge('Bearer resource_metadata="https://mcp.vendor.test/.well-known/oauth-protected-resource"')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does not match a plain Bearer 401 (an invalid API key, not an OAuth requirement)", () => {
|
|
19
|
+
expect(isOAuthChallenge('Bearer error="invalid_token"')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not match a non-Bearer scheme", () => {
|
|
23
|
+
expect(isOAuthChallenge('Basic realm="OAuth"')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("is empty-safe", () => {
|
|
27
|
+
expect(isOAuthChallenge("")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("parseResourceMetadataUrl", () => {
|
|
32
|
+
it("extracts the resource_metadata URL", () => {
|
|
33
|
+
expect(parseResourceMetadataUrl('Bearer realm="OAuth", resource_metadata="https://mcp.vendor.test/.well-known/oauth-protected-resource"')).toBe(
|
|
34
|
+
"https://mcp.vendor.test/.well-known/oauth-protected-resource",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined when absent", () => {
|
|
39
|
+
expect(parseResourceMetadataUrl('Bearer realm="OAuth"')).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the egress check: scheme first, then every resolved address, literal
|
|
3
|
+
* hosts without a lookup, an unresolvable name refused, and the caller's
|
|
4
|
+
* signal bounding a resolver that never answers.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { egressPolicyForPosture } from "../egress/address.js";
|
|
9
|
+
import { checkEgress, describeRefusal, EgressError, type LookupFn } from "../egress/check.js";
|
|
10
|
+
|
|
11
|
+
const strict = egressPolicyForPosture("strict");
|
|
12
|
+
const relaxed = egressPolicyForPosture("relaxed");
|
|
13
|
+
|
|
14
|
+
function table(entries: Record<string, readonly string[]>): LookupFn {
|
|
15
|
+
return async (hostname) => {
|
|
16
|
+
const found = entries[hostname];
|
|
17
|
+
if (found === undefined) throw new Error(`ENOTFOUND ${hostname}`);
|
|
18
|
+
return found;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("checkEgress", () => {
|
|
23
|
+
it("refuses a malformed URL before resolving anything", async () => {
|
|
24
|
+
const result = await checkEgress("not a url", strict, { lookup: table({}) });
|
|
25
|
+
expect(result).toEqual({ ok: false, refusal: { kind: "invalid-url", url: "not a url" } });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(["file:///etc/passwd", "ftp://vendor.test/x", "gopher://vendor.test"])("refuses the scheme of %s", async (url) => {
|
|
29
|
+
const result = await checkEgress(url, strict, { lookup: table({}) });
|
|
30
|
+
expect(result.ok).toBe(false);
|
|
31
|
+
if (!result.ok) expect(result.refusal.kind).toBe("unsupported-scheme");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("judges a literal IP host without a lookup", async () => {
|
|
35
|
+
let lookups = 0;
|
|
36
|
+
const lookup: LookupFn = async () => {
|
|
37
|
+
lookups += 1;
|
|
38
|
+
return [];
|
|
39
|
+
};
|
|
40
|
+
const refused = await checkEgress("http://127.0.0.1:8080/x", strict, { lookup });
|
|
41
|
+
expect(refused.ok).toBe(false);
|
|
42
|
+
if (!refused.ok) expect(refused.refusal).toMatchObject({ kind: "blocked", address: "127.0.0.1", reason: "loopback", policy: "strict" });
|
|
43
|
+
const allowed = await checkEgress("http://127.0.0.1:3000/mcp", relaxed, { lookup });
|
|
44
|
+
expect(allowed).toMatchObject({ ok: true, addresses: ["127.0.0.1"] });
|
|
45
|
+
const bracketed = await checkEgress("http://[::1]/", strict, { lookup });
|
|
46
|
+
expect(bracketed.ok).toBe(false);
|
|
47
|
+
expect(lookups).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("refuses a name when ANY of its addresses is blocked", async () => {
|
|
51
|
+
const lookup = table({ "split.vendor.test": ["104.16.0.1", "10.0.0.7"] });
|
|
52
|
+
const result = await checkEgress("https://split.vendor.test/mcp", strict, { lookup });
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
if (!result.ok) expect(result.refusal).toMatchObject({ kind: "blocked", hostname: "split.vendor.test", address: "10.0.0.7" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("allows a name whose every address the policy allows, and reports them", async () => {
|
|
58
|
+
const lookup = table({ "mcp.vendor.test": ["104.16.0.1", "2606:4700::6810:1"] });
|
|
59
|
+
const result = await checkEgress("https://mcp.vendor.test/mcp", strict, { lookup });
|
|
60
|
+
expect(result).toMatchObject({ ok: true, addresses: ["104.16.0.1", "2606:4700::6810:1"] });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("refuses an unresolvable name", async () => {
|
|
64
|
+
const result = await checkEgress("https://nowhere.vendor.test/", strict, { lookup: table({}) });
|
|
65
|
+
expect(result).toMatchObject({ ok: false, refusal: { kind: "unresolvable", hostname: "nowhere.vendor.test" } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("refuses a name that resolves to nothing", async () => {
|
|
69
|
+
const result = await checkEgress("https://empty.vendor.test/", strict, { lookup: table({ "empty.vendor.test": [] }) });
|
|
70
|
+
expect(result).toMatchObject({ ok: false, refusal: { kind: "unresolvable" } });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("gives up on a resolver that never answers when the signal aborts", async () => {
|
|
74
|
+
const hanging: LookupFn = () => new Promise(() => undefined);
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const pending = checkEgress("https://slow.vendor.test/", strict, { lookup: hanging, signal: controller.signal });
|
|
77
|
+
controller.abort(new Error("deadline"));
|
|
78
|
+
await expect(pending).rejects.toThrow("deadline");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects at once on an already-aborted signal", async () => {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
controller.abort(new Error("gone"));
|
|
84
|
+
await expect(checkEgress("https://slow.vendor.test/", strict, { lookup: table({}), signal: controller.signal })).rejects.toThrow("gone");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("describeRefusal and EgressError", () => {
|
|
89
|
+
it("renders one sentence per refusal and the error carries the refusal", () => {
|
|
90
|
+
const url = new URL("https://split.vendor.test/mcp");
|
|
91
|
+
const error = new EgressError({ kind: "blocked", url, hostname: url.hostname, address: "10.0.0.7", reason: "private (RFC 1918)", policy: "strict" });
|
|
92
|
+
expect(error.name).toBe("EgressError");
|
|
93
|
+
expect(error.message).toBe("Refusing to reach split.vendor.test: it resolves to 10.0.0.7, a private (RFC 1918) address the strict egress policy does not dial.");
|
|
94
|
+
expect(error.refusal.kind).toBe("blocked");
|
|
95
|
+
expect(describeRefusal({ kind: "unsupported-scheme", url: new URL("ftp://x.test/"), scheme: "ftp" })).toBe('Unsupported URL scheme "ftp": only http and https are allowed.');
|
|
96
|
+
expect(describeRefusal({ kind: "too-many-redirects", url, hops: 3 })).toBe("Refusing to follow more than 3 redirects from https://split.vendor.test/mcp.");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the guarded fetch: the first URL and every hop judged, a caller's
|
|
3
|
+
* `redirect: "manual"` honoured, `redirect: "error"` thrown like fetch, the
|
|
4
|
+
* hop budget, the Authorization header dropped across origins, and the
|
|
5
|
+
* method rewrite a 303 (or a 301/302 on a POST) implies.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { egressPolicyForPosture } from "../egress/address.js";
|
|
10
|
+
import { EgressError, type LookupFn } from "../egress/check.js";
|
|
11
|
+
import { asFetch, guardedFetch, type OutboundFetch } from "../egress/fetch.js";
|
|
12
|
+
|
|
13
|
+
const strict = egressPolicyForPosture("strict");
|
|
14
|
+
|
|
15
|
+
const lookup: LookupFn = async (hostname) => {
|
|
16
|
+
const table: Record<string, readonly string[]> = {
|
|
17
|
+
"a.vendor.test": ["104.16.0.1"],
|
|
18
|
+
"b.vendor.test": ["104.16.0.2"],
|
|
19
|
+
"internal.vendor.test": ["10.0.0.5"],
|
|
20
|
+
};
|
|
21
|
+
const found = table[hostname];
|
|
22
|
+
if (found === undefined) throw new Error(`ENOTFOUND ${hostname}`);
|
|
23
|
+
return found;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface Seen {
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly method: string;
|
|
29
|
+
readonly headers: Headers;
|
|
30
|
+
readonly body: RequestInit["body"];
|
|
31
|
+
readonly redirect: RequestInit["redirect"];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scripted(answers: Record<string, () => Response>): { fetchImpl: OutboundFetch; seen: Seen[] } {
|
|
35
|
+
const seen: Seen[] = [];
|
|
36
|
+
const fetchImpl: OutboundFetch = async (url, init) => {
|
|
37
|
+
const key = String(url);
|
|
38
|
+
seen.push({ url: key, method: init?.method ?? "GET", headers: new Headers(init?.headers), body: init?.body, redirect: init?.redirect });
|
|
39
|
+
const answer = answers[key];
|
|
40
|
+
if (answer === undefined) throw new Error(`unscripted ${key}`);
|
|
41
|
+
return answer();
|
|
42
|
+
};
|
|
43
|
+
return { fetchImpl, seen };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const redirect = (status: number, location: string): Response => new Response(null, { status, headers: { location } });
|
|
47
|
+
const ok = (): Response => new Response("ok", { status: 200 });
|
|
48
|
+
|
|
49
|
+
describe("guardedFetch", () => {
|
|
50
|
+
it("judges the URL before dialling and refuses a blocked one without calling fetch", async () => {
|
|
51
|
+
const { fetchImpl, seen } = scripted({});
|
|
52
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
53
|
+
await expect(fetchGuarded("https://internal.vendor.test/mcp")).rejects.toBeInstanceOf(EgressError);
|
|
54
|
+
expect(seen).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("dials an allowed URL with the caller's init and asks the underlying fetch for manual redirects", async () => {
|
|
58
|
+
const { fetchImpl, seen } = scripted({ "https://a.vendor.test/mcp": ok });
|
|
59
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
60
|
+
const response = await fetchGuarded("https://a.vendor.test/mcp", { method: "POST", body: "{}", headers: { Accept: "application/json" } });
|
|
61
|
+
expect(response.status).toBe(200);
|
|
62
|
+
expect(seen).toHaveLength(1);
|
|
63
|
+
expect(seen[0]).toMatchObject({ method: "POST", body: "{}", redirect: "manual" });
|
|
64
|
+
expect(seen[0]?.headers.get("accept")).toBe("application/json");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("follows a redirect and judges the hop, refusing one that lands on a blocked address", async () => {
|
|
68
|
+
const { fetchImpl, seen } = scripted({
|
|
69
|
+
"https://a.vendor.test/mcp": () => redirect(302, "https://internal.vendor.test/mcp"),
|
|
70
|
+
});
|
|
71
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
72
|
+
await expect(fetchGuarded("https://a.vendor.test/mcp")).rejects.toMatchObject({ refusal: { kind: "blocked", hostname: "internal.vendor.test" } });
|
|
73
|
+
expect(seen.map((s) => s.url)).toEqual(["https://a.vendor.test/mcp"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("follows an allowed hop and resolves a relative Location against the current URL", async () => {
|
|
77
|
+
const { fetchImpl, seen } = scripted({
|
|
78
|
+
"https://a.vendor.test/mcp": () => redirect(301, "/mcp/"),
|
|
79
|
+
"https://a.vendor.test/mcp/": ok,
|
|
80
|
+
});
|
|
81
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
82
|
+
const response = await fetchGuarded("https://a.vendor.test/mcp");
|
|
83
|
+
expect(response.status).toBe(200);
|
|
84
|
+
expect(seen.map((s) => s.url)).toEqual(["https://a.vendor.test/mcp", "https://a.vendor.test/mcp/"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns the 3xx untouched when the caller asked for manual redirects", async () => {
|
|
88
|
+
const { fetchImpl, seen } = scripted({ "https://a.vendor.test/authorize": () => redirect(302, "https://b.vendor.test/login") });
|
|
89
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
90
|
+
const response = await fetchGuarded("https://a.vendor.test/authorize", { redirect: "manual" });
|
|
91
|
+
expect(response.status).toBe(302);
|
|
92
|
+
expect(seen).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("throws a TypeError on a redirect when the caller asked for redirect: error", async () => {
|
|
96
|
+
const { fetchImpl } = scripted({ "https://a.vendor.test/x": () => redirect(302, "https://b.vendor.test/y") });
|
|
97
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
98
|
+
await expect(fetchGuarded("https://a.vendor.test/x", { redirect: "error" })).rejects.toBeInstanceOf(TypeError);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("refuses past the hop budget", async () => {
|
|
102
|
+
const { fetchImpl, seen } = scripted({
|
|
103
|
+
"https://a.vendor.test/1": () => redirect(302, "https://a.vendor.test/2"),
|
|
104
|
+
"https://a.vendor.test/2": () => redirect(302, "https://a.vendor.test/3"),
|
|
105
|
+
"https://a.vendor.test/3": () => redirect(302, "https://a.vendor.test/4"),
|
|
106
|
+
"https://a.vendor.test/4": () => redirect(302, "https://a.vendor.test/5"),
|
|
107
|
+
});
|
|
108
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
109
|
+
await expect(fetchGuarded("https://a.vendor.test/1")).rejects.toMatchObject({ refusal: { kind: "too-many-redirects", hops: 3 } });
|
|
110
|
+
expect(seen).toHaveLength(4);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("drops the Authorization header on a cross-origin hop and keeps it on a same-origin one", async () => {
|
|
114
|
+
const { fetchImpl, seen } = scripted({
|
|
115
|
+
"https://a.vendor.test/x": () => redirect(307, "https://a.vendor.test/y"),
|
|
116
|
+
"https://a.vendor.test/y": () => redirect(307, "https://b.vendor.test/z"),
|
|
117
|
+
"https://b.vendor.test/z": ok,
|
|
118
|
+
});
|
|
119
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
120
|
+
await fetchGuarded("https://a.vendor.test/x", { method: "POST", body: "{}", headers: { Authorization: "Bearer t" } });
|
|
121
|
+
expect(seen[0]?.headers.get("authorization")).toBe("Bearer t");
|
|
122
|
+
expect(seen[1]?.headers.get("authorization")).toBe("Bearer t");
|
|
123
|
+
expect(seen[2]?.headers.get("authorization")).toBeNull();
|
|
124
|
+
// 307 keeps the method and the body.
|
|
125
|
+
expect(seen[2]).toMatchObject({ method: "POST", body: "{}" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("turns a 303, or a 301/302 answering a POST, into a GET without a body", async () => {
|
|
129
|
+
const { fetchImpl, seen } = scripted({
|
|
130
|
+
"https://a.vendor.test/post": () => redirect(303, "https://a.vendor.test/result"),
|
|
131
|
+
"https://a.vendor.test/result": ok,
|
|
132
|
+
"https://a.vendor.test/post2": () => redirect(302, "https://a.vendor.test/result2"),
|
|
133
|
+
"https://a.vendor.test/result2": ok,
|
|
134
|
+
});
|
|
135
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup });
|
|
136
|
+
await fetchGuarded("https://a.vendor.test/post", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } });
|
|
137
|
+
expect(seen[1]).toMatchObject({ method: "GET", body: undefined });
|
|
138
|
+
expect(seen[1]?.headers.get("content-type")).toBeNull();
|
|
139
|
+
await fetchGuarded("https://a.vendor.test/post2", { method: "POST", body: "{}" });
|
|
140
|
+
expect(seen[3]).toMatchObject({ method: "GET", body: undefined });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("asFetch wears the global fetch's shape and refuses a Request input rather than flattening it", async () => {
|
|
144
|
+
const { fetchImpl, seen } = scripted({ "https://a.vendor.test/mcp": ok });
|
|
145
|
+
const wrapped = asFetch(guardedFetch(strict, { fetchImpl, lookup }));
|
|
146
|
+
await expect(wrapped("https://a.vendor.test/mcp")).resolves.toHaveProperty("status", 200);
|
|
147
|
+
await expect(wrapped(new URL("https://a.vendor.test/mcp"))).resolves.toHaveProperty("status", 200);
|
|
148
|
+
await expect(wrapped(new Request("https://a.vendor.test/mcp"))).rejects.toBeInstanceOf(TypeError);
|
|
149
|
+
expect(seen).toHaveLength(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("passes the caller's signal through to the check and to every hop", async () => {
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
const { fetchImpl } = scripted({});
|
|
155
|
+
const hanging: LookupFn = () => new Promise(() => undefined);
|
|
156
|
+
const fetchGuarded = guardedFetch(strict, { fetchImpl, lookup: hanging });
|
|
157
|
+
const pending = fetchGuarded("https://a.vendor.test/mcp", { signal: controller.signal });
|
|
158
|
+
controller.abort(new Error("deadline"));
|
|
159
|
+
await expect(pending).rejects.toThrow("deadline");
|
|
160
|
+
});
|
|
161
|
+
});
|