codex-plugin-doctor 1.51.0 → 1.52.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/README.md +17 -6
- package/dist/core/bounded-http-client.d.ts +23 -0
- package/dist/core/bounded-http-client.js +240 -0
- package/dist/core/output-contract.js +23 -1
- package/dist/core/release-check.d.ts +2 -0
- package/dist/core/release-check.js +2 -0
- package/dist/core/release-evidence.d.ts +2 -0
- package/dist/core/release-evidence.js +111 -7
- package/dist/core/remote-mcp-probe.d.ts +16 -0
- package/dist/core/remote-mcp-probe.js +226 -0
- package/dist/core/remote-network-policy.d.ts +24 -0
- package/dist/core/remote-network-policy.js +125 -0
- package/dist/core/remote-oauth-readiness.d.ts +12 -0
- package/dist/core/remote-oauth-readiness.js +295 -0
- package/dist/core/remote-url-policy.d.ts +7 -0
- package/dist/core/remote-url-policy.js +49 -0
- package/dist/core/runtime-plan.d.ts +3 -0
- package/dist/core/runtime-plan.js +34 -2
- package/dist/core/runtime-policy.js +5 -1
- package/dist/core/runtime-probe.d.ts +7 -0
- package/dist/core/runtime-probe.js +72 -29
- package/dist/core/validate-plugin.js +32 -1
- package/dist/domain/types.d.ts +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +2 -0
- package/dist/mcp/generic-mcp-doctor.js +10 -3
- package/dist/reporting/render-markdown-report.js +25 -12
- package/dist/reporting/render-text-report.js +21 -10
- package/dist/rules/rule-catalog.js +181 -1
- package/dist/run-cli.js +98 -5
- package/dist/security/security-audit.js +34 -7
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { BoundedHttpError, requestBoundedHttp } from "./bounded-http-client.js";
|
|
2
|
+
import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
|
|
3
|
+
import { checkRemoteOAuthReadiness } from "./remote-oauth-readiness.js";
|
|
4
|
+
import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
|
|
5
|
+
import { packageVersion } from "../version.js";
|
|
6
|
+
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
7
|
+
function createScorecard() {
|
|
8
|
+
return {
|
|
9
|
+
transport: "skipped",
|
|
10
|
+
networkSafety: "skipped",
|
|
11
|
+
initialize: "skipped",
|
|
12
|
+
contentType: "skipped",
|
|
13
|
+
session: "absent",
|
|
14
|
+
protocolHeaders: "skipped",
|
|
15
|
+
authorization: "skipped",
|
|
16
|
+
overall: "skipped"
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function failure(id, message, impact, suggestedFix) {
|
|
20
|
+
return { id, severity: "fail", message, impact, suggestedFix };
|
|
21
|
+
}
|
|
22
|
+
function finalize(scorecard, findings) {
|
|
23
|
+
scorecard.overall = findings.some((finding) => finding.severity === "fail")
|
|
24
|
+
? "fail"
|
|
25
|
+
: findings.some((finding) => finding.severity === "warn")
|
|
26
|
+
? "warn"
|
|
27
|
+
: scorecard.initialize === "pass" || scorecard.authorization === "pass"
|
|
28
|
+
? "pass"
|
|
29
|
+
: "skipped";
|
|
30
|
+
return { findings, scorecard };
|
|
31
|
+
}
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function responseHeader(headers, name) {
|
|
36
|
+
const value = headers[name];
|
|
37
|
+
return typeof value === "string" ? value : null;
|
|
38
|
+
}
|
|
39
|
+
function mediaType(value) {
|
|
40
|
+
return value?.split(";", 1)[0]?.trim().toLowerCase() ?? null;
|
|
41
|
+
}
|
|
42
|
+
function parseJsonObject(source) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(source);
|
|
45
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isInitializeResponseCandidate(message) {
|
|
52
|
+
return message.id === 1;
|
|
53
|
+
}
|
|
54
|
+
function findSseInitializeResponse(body) {
|
|
55
|
+
const text = body.toString("utf8").replace(/\r\n/g, "\n");
|
|
56
|
+
let offset = 0;
|
|
57
|
+
while (offset < text.length) {
|
|
58
|
+
const boundary = text.indexOf("\n\n", offset);
|
|
59
|
+
if (boundary === -1) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const data = text.slice(offset, boundary).split("\n")
|
|
63
|
+
.filter((line) => line.startsWith("data:"))
|
|
64
|
+
.map((line) => line.slice(5).replace(/^ /, ""));
|
|
65
|
+
const message = data.length > 0 ? parseJsonObject(data.join("\n")) : null;
|
|
66
|
+
if (message && isInitializeResponseCandidate(message)) {
|
|
67
|
+
return message;
|
|
68
|
+
}
|
|
69
|
+
offset = boundary + 2;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function parseInitializeResponse(body, contentType) {
|
|
74
|
+
return contentType === "text/event-stream"
|
|
75
|
+
? findSseInitializeResponse(body)
|
|
76
|
+
: parseJsonObject(body.toString("utf8"));
|
|
77
|
+
}
|
|
78
|
+
function isValidInitializeResponse(message) {
|
|
79
|
+
return (message.jsonrpc === "2.0" &&
|
|
80
|
+
message.id === 1 &&
|
|
81
|
+
isPlainObject(message.result) &&
|
|
82
|
+
message.result.protocolVersion === MCP_PROTOCOL_VERSION &&
|
|
83
|
+
isPlainObject(message.result.capabilities) &&
|
|
84
|
+
isPlainObject(message.result.serverInfo) &&
|
|
85
|
+
typeof message.result.serverInfo.name === "string" &&
|
|
86
|
+
typeof message.result.serverInfo.version === "string");
|
|
87
|
+
}
|
|
88
|
+
function validSessionId(value) {
|
|
89
|
+
return value !== null && /^[\x21-\x7e]+$/.test(value);
|
|
90
|
+
}
|
|
91
|
+
function transportFailureId(error) {
|
|
92
|
+
if (error instanceof BoundedHttpError) {
|
|
93
|
+
if (error.code === "REMOTE_HTTP_TIMEOUT")
|
|
94
|
+
return "plugin.runtime.remote.transport.timeout";
|
|
95
|
+
if (error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE")
|
|
96
|
+
return "plugin.runtime.remote.transport.response_too_large";
|
|
97
|
+
}
|
|
98
|
+
return "plugin.runtime.remote.transport.failed";
|
|
99
|
+
}
|
|
100
|
+
function transportStatus(error) {
|
|
101
|
+
return error instanceof RemoteNetworkPolicyError
|
|
102
|
+
? "skipped"
|
|
103
|
+
: "fail";
|
|
104
|
+
}
|
|
105
|
+
export async function probeRemoteMcpServer(serverName, rawUrl, options = {}) {
|
|
106
|
+
const scorecard = createScorecard();
|
|
107
|
+
const findings = [];
|
|
108
|
+
const request = options.request ?? requestBoundedHttp;
|
|
109
|
+
if (!options.allowNetwork) {
|
|
110
|
+
scorecard.networkSafety = "fail";
|
|
111
|
+
findings.push(failure("plugin.runtime.remote.network_not_approved", `The remote MCP server \`${serverName}\` was not contacted because network probing is not approved.`, "Remote MCP initialization can create outbound network traffic and must be explicitly approved.", "Enable remote network probing only after reviewing the server endpoint."));
|
|
112
|
+
return finalize(scorecard, findings);
|
|
113
|
+
}
|
|
114
|
+
const inspection = inspectRemoteMcpUrl(rawUrl);
|
|
115
|
+
if (inspection.issues.length > 0) {
|
|
116
|
+
scorecard.networkSafety = "fail";
|
|
117
|
+
findings.push(failure("plugin.runtime.remote.url.invalid", `The remote MCP server \`${serverName}\` has an unsafe or unsupported endpoint URL.`, "Unsafe remote endpoint URLs can bypass network controls or expose credentials.", "Use an absolute HTTP or HTTPS endpoint without credentials, query parameters, fragments, or IP literals."));
|
|
118
|
+
return finalize(scorecard, findings);
|
|
119
|
+
}
|
|
120
|
+
scorecard.networkSafety = "pass";
|
|
121
|
+
const initializeBody = JSON.stringify({
|
|
122
|
+
jsonrpc: "2.0",
|
|
123
|
+
id: 1,
|
|
124
|
+
method: "initialize",
|
|
125
|
+
params: {
|
|
126
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
127
|
+
capabilities: {},
|
|
128
|
+
clientInfo: { name: "Codex Plugin Doctor", version: packageVersion }
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
let initializeResponse;
|
|
132
|
+
try {
|
|
133
|
+
initializeResponse = await request(rawUrl, {
|
|
134
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
135
|
+
lookup: options.lookup,
|
|
136
|
+
timeoutMs: options.requestTimeoutMs,
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: initializeBody,
|
|
139
|
+
headers: {
|
|
140
|
+
Accept: "application/json, text/event-stream",
|
|
141
|
+
"Content-Type": "application/json"
|
|
142
|
+
},
|
|
143
|
+
stopAfter: (body) => findSseInitializeResponse(body) !== null
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
scorecard.transport = transportStatus(error);
|
|
148
|
+
if (error instanceof RemoteNetworkPolicyError) {
|
|
149
|
+
scorecard.networkSafety = "fail";
|
|
150
|
+
}
|
|
151
|
+
findings.push(failure(transportFailureId(error), `The remote MCP server \`${serverName}\` could not complete a bounded initialize request.`, "A failed transport prevents safe MCP protocol negotiation.", "Verify the endpoint is reachable and complies with the configured remote network policy."));
|
|
152
|
+
return finalize(scorecard, findings);
|
|
153
|
+
}
|
|
154
|
+
scorecard.transport = "pass";
|
|
155
|
+
if (initializeResponse.statusCode === 401) {
|
|
156
|
+
const challenge = initializeResponse.headers["www-authenticate"];
|
|
157
|
+
const readiness = await checkRemoteOAuthReadiness(inspection.sanitizedUrl ?? rawUrl, challenge === undefined ? [] : [challenge], {
|
|
158
|
+
request,
|
|
159
|
+
requestOptions: {
|
|
160
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
161
|
+
lookup: options.lookup,
|
|
162
|
+
timeoutMs: options.requestTimeoutMs
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
scorecard.authorization = readiness.status;
|
|
166
|
+
findings.push(...readiness.findings);
|
|
167
|
+
return finalize(scorecard, findings);
|
|
168
|
+
}
|
|
169
|
+
if (initializeResponse.statusCode !== 200) {
|
|
170
|
+
scorecard.initialize = "fail";
|
|
171
|
+
findings.push(failure("plugin.runtime.remote.http_status.invalid", `The remote MCP server \`${serverName}\` returned an unexpected initialize HTTP status.`, "Streamable HTTP MCP initialization requires a successful response before protocol negotiation can continue.", "Return HTTP 200 for initialize, or configure authorization before probing a protected endpoint."));
|
|
172
|
+
return finalize(scorecard, findings);
|
|
173
|
+
}
|
|
174
|
+
const contentType = mediaType(responseHeader(initializeResponse.headers, "content-type"));
|
|
175
|
+
if (contentType !== "application/json" && contentType !== "text/event-stream") {
|
|
176
|
+
scorecard.contentType = "fail";
|
|
177
|
+
scorecard.initialize = "fail";
|
|
178
|
+
findings.push(failure("plugin.runtime.remote.content_type.invalid", `The remote MCP server \`${serverName}\` returned an unsupported initialize content type.`, "MCP initialization responses must be JSON or Server-Sent Events so the JSON-RPC result can be validated.", "Return application/json or text/event-stream with a JSON-RPC initialize response."));
|
|
179
|
+
return finalize(scorecard, findings);
|
|
180
|
+
}
|
|
181
|
+
scorecard.contentType = "pass";
|
|
182
|
+
const sessionId = responseHeader(initializeResponse.headers, "mcp-session-id");
|
|
183
|
+
if (sessionId !== null && !validSessionId(sessionId)) {
|
|
184
|
+
scorecard.session = "present-invalid";
|
|
185
|
+
scorecard.initialize = "fail";
|
|
186
|
+
findings.push(failure("plugin.runtime.remote.session.invalid", `The remote MCP server \`${serverName}\` returned an invalid MCP session header.`, "Invalid session identifiers cannot be safely replayed on the initialized notification.", "Return MCP-Session-Id only as visible ASCII characters."));
|
|
187
|
+
return finalize(scorecard, findings);
|
|
188
|
+
}
|
|
189
|
+
if (sessionId !== null) {
|
|
190
|
+
scorecard.session = "present-valid";
|
|
191
|
+
}
|
|
192
|
+
const initializeMessage = parseInitializeResponse(initializeResponse.body, contentType);
|
|
193
|
+
if (!initializeMessage || !isValidInitializeResponse(initializeMessage)) {
|
|
194
|
+
scorecard.initialize = "fail";
|
|
195
|
+
findings.push(failure("plugin.runtime.remote.initialize.invalid", `The remote MCP server \`${serverName}\` returned an invalid initialize JSON-RPC result.`, "A malformed initialize result prevents protocol version negotiation.", "Return a JSON-RPC 2.0 result with id 1 and protocol version 2025-11-25."));
|
|
196
|
+
return finalize(scorecard, findings);
|
|
197
|
+
}
|
|
198
|
+
scorecard.initialize = "pass";
|
|
199
|
+
try {
|
|
200
|
+
const initializedResponse = await request(rawUrl, {
|
|
201
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
202
|
+
lookup: options.lookup,
|
|
203
|
+
timeoutMs: options.requestTimeoutMs,
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
206
|
+
headers: {
|
|
207
|
+
Accept: "application/json, text/event-stream",
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
210
|
+
...(sessionId === null ? {} : { "MCP-Session-Id": sessionId })
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
if (initializedResponse.statusCode < 200 || initializedResponse.statusCode >= 300) {
|
|
214
|
+
scorecard.protocolHeaders = "fail";
|
|
215
|
+
findings.push(failure("plugin.runtime.remote.initialized.failed", `The remote MCP server \`${serverName}\` did not acknowledge the initialized notification.`, "The MCP session may not be ready for subsequent protocol traffic.", "Accept a successful HTTP response to notifications/initialized at the same MCP endpoint."));
|
|
216
|
+
return finalize(scorecard, findings);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
scorecard.protocolHeaders = "fail";
|
|
221
|
+
findings.push(failure("plugin.runtime.remote.initialized.failed", `The remote MCP server \`${serverName}\` could not receive the initialized notification.`, "The MCP session may not be ready for subsequent protocol traffic.", "Accept a successful HTTP response to notifications/initialized at the same MCP endpoint."));
|
|
222
|
+
return finalize(scorecard, findings);
|
|
223
|
+
}
|
|
224
|
+
scorecard.protocolHeaders = "pass";
|
|
225
|
+
return finalize(scorecard, findings);
|
|
226
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ResolvedRemoteTarget {
|
|
2
|
+
hostname: string;
|
|
3
|
+
address: string;
|
|
4
|
+
family: 4 | 6;
|
|
5
|
+
local: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface RemoteLookup {
|
|
8
|
+
(hostname: string, options: {
|
|
9
|
+
all: true;
|
|
10
|
+
verbatim: true;
|
|
11
|
+
}): Promise<Array<{
|
|
12
|
+
address: string;
|
|
13
|
+
family: 4 | 6;
|
|
14
|
+
}>>;
|
|
15
|
+
}
|
|
16
|
+
export interface ResolveRemoteTargetOptions {
|
|
17
|
+
allowLocalNetwork?: boolean;
|
|
18
|
+
lookup?: RemoteLookup;
|
|
19
|
+
}
|
|
20
|
+
export declare class RemoteNetworkPolicyError extends Error {
|
|
21
|
+
readonly code: "REMOTE_TARGET_EMPTY" | "REMOTE_TARGET_FORBIDDEN" | "REMOTE_TARGET_INVALID_ADDRESS" | "REMOTE_TARGET_LOOKUP_FAILED" | "REMOTE_TARGET_URL_INVALID";
|
|
22
|
+
constructor(code: "REMOTE_TARGET_EMPTY" | "REMOTE_TARGET_FORBIDDEN" | "REMOTE_TARGET_INVALID_ADDRESS" | "REMOTE_TARGET_LOOKUP_FAILED" | "REMOTE_TARGET_URL_INVALID", message: string);
|
|
23
|
+
}
|
|
24
|
+
export declare function resolveRemoteTarget(url: URL, options?: ResolveRemoteTargetOptions): Promise<ResolvedRemoteTarget>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { BlockList, isIP, SocketAddress } from "node:net";
|
|
3
|
+
export class RemoteNetworkPolicyError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "RemoteNetworkPolicyError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const blockedAddresses = new BlockList();
|
|
12
|
+
const loopbackAddresses = new BlockList();
|
|
13
|
+
const mappedIpv4Addresses = new BlockList();
|
|
14
|
+
const ipv4CompatibleAddresses = new BlockList();
|
|
15
|
+
function addIpv4Subnet(address, prefix) {
|
|
16
|
+
blockedAddresses.addSubnet(address, prefix, "ipv4");
|
|
17
|
+
blockedAddresses.addSubnet(`::ffff:${address}`, prefix + 96, "ipv6");
|
|
18
|
+
}
|
|
19
|
+
function addLoopbackSubnet(address, prefix) {
|
|
20
|
+
loopbackAddresses.addSubnet(address, prefix, "ipv4");
|
|
21
|
+
loopbackAddresses.addSubnet(`::ffff:${address}`, prefix + 96, "ipv6");
|
|
22
|
+
}
|
|
23
|
+
for (const [address, prefix] of [
|
|
24
|
+
["0.0.0.0", 8],
|
|
25
|
+
["10.0.0.0", 8],
|
|
26
|
+
["100.64.0.0", 10],
|
|
27
|
+
["169.254.0.0", 16],
|
|
28
|
+
["172.16.0.0", 12],
|
|
29
|
+
["192.0.0.0", 24],
|
|
30
|
+
["192.0.2.0", 24],
|
|
31
|
+
["192.88.99.0", 24],
|
|
32
|
+
["192.168.0.0", 16],
|
|
33
|
+
["198.18.0.0", 15],
|
|
34
|
+
["198.51.100.0", 24],
|
|
35
|
+
["203.0.113.0", 24],
|
|
36
|
+
["224.0.0.0", 4],
|
|
37
|
+
["240.0.0.0", 4]
|
|
38
|
+
]) {
|
|
39
|
+
addIpv4Subnet(address, prefix);
|
|
40
|
+
}
|
|
41
|
+
addLoopbackSubnet("127.0.0.0", 8);
|
|
42
|
+
blockedAddresses.addAddress("100.100.100.200", "ipv4");
|
|
43
|
+
blockedAddresses.addAddress("100.100.100.100", "ipv4");
|
|
44
|
+
for (const [address, prefix] of [
|
|
45
|
+
["::", 128],
|
|
46
|
+
["100::", 64],
|
|
47
|
+
["2001::", 23],
|
|
48
|
+
["2001:db8::", 32],
|
|
49
|
+
["2002::", 16],
|
|
50
|
+
["3fff::", 20],
|
|
51
|
+
["5f00::", 16],
|
|
52
|
+
["fc00::", 7],
|
|
53
|
+
["fe80::", 10],
|
|
54
|
+
["ff00::", 8]
|
|
55
|
+
]) {
|
|
56
|
+
blockedAddresses.addSubnet(address, prefix, "ipv6");
|
|
57
|
+
}
|
|
58
|
+
loopbackAddresses.addAddress("::1", "ipv6");
|
|
59
|
+
mappedIpv4Addresses.addSubnet("::ffff:0:0", 96, "ipv6");
|
|
60
|
+
ipv4CompatibleAddresses.addSubnet("::", 96, "ipv6");
|
|
61
|
+
for (const [address, prefix] of [
|
|
62
|
+
["64:ff9b::", 96],
|
|
63
|
+
["64:ff9b:1::", 48],
|
|
64
|
+
["100:0:0:1::", 64]
|
|
65
|
+
]) {
|
|
66
|
+
blockedAddresses.addSubnet(address, prefix, "ipv6");
|
|
67
|
+
}
|
|
68
|
+
function normalizeAddress(address, family) {
|
|
69
|
+
if ((family !== 4 && family !== 6) || isIP(address) !== family) {
|
|
70
|
+
throw new RemoteNetworkPolicyError("REMOTE_TARGET_INVALID_ADDRESS", "Remote target DNS resolution returned an invalid address.");
|
|
71
|
+
}
|
|
72
|
+
const normalized = new SocketAddress({
|
|
73
|
+
address,
|
|
74
|
+
port: 0,
|
|
75
|
+
family: family === 4 ? "ipv4" : "ipv6"
|
|
76
|
+
});
|
|
77
|
+
return { address: normalized.address, family };
|
|
78
|
+
}
|
|
79
|
+
function isListed(address, family, list) {
|
|
80
|
+
return list.check(address, family === 4 ? "ipv4" : "ipv6");
|
|
81
|
+
}
|
|
82
|
+
export async function resolveRemoteTarget(url, options = {}) {
|
|
83
|
+
const hostname = url.hostname.startsWith("[") && url.hostname.endsWith("]")
|
|
84
|
+
? url.hostname.slice(1, -1)
|
|
85
|
+
: url.hostname;
|
|
86
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || !hostname || isIP(hostname) !== 0) {
|
|
87
|
+
throw new RemoteNetworkPolicyError("REMOTE_TARGET_URL_INVALID", "Remote target URL must use HTTP or HTTPS with a hostname, not an IP address literal.");
|
|
88
|
+
}
|
|
89
|
+
const lookup = options.lookup ?? (async (hostname, lookupOptions) => {
|
|
90
|
+
const answers = await dnsLookup(hostname, lookupOptions);
|
|
91
|
+
return answers.map((answer) => ({
|
|
92
|
+
address: answer.address,
|
|
93
|
+
family: answer.family
|
|
94
|
+
}));
|
|
95
|
+
});
|
|
96
|
+
let answers;
|
|
97
|
+
try {
|
|
98
|
+
answers = await lookup(hostname, { all: true, verbatim: true });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new RemoteNetworkPolicyError("REMOTE_TARGET_LOOKUP_FAILED", "Remote target DNS resolution failed.");
|
|
102
|
+
}
|
|
103
|
+
if (answers.length === 0) {
|
|
104
|
+
throw new RemoteNetworkPolicyError("REMOTE_TARGET_EMPTY", "Remote target DNS resolution returned no addresses.");
|
|
105
|
+
}
|
|
106
|
+
const normalizedAnswers = answers.map((answer) => normalizeAddress(answer.address, answer.family));
|
|
107
|
+
for (const answer of normalizedAnswers) {
|
|
108
|
+
const isLoopback = isListed(answer.address, answer.family, loopbackAddresses);
|
|
109
|
+
const isMappedIpv4 = answer.family === 6 && isListed(answer.address, answer.family, mappedIpv4Addresses);
|
|
110
|
+
const isIpv4Compatible = answer.family === 6 && isListed(answer.address, answer.family, ipv4CompatibleAddresses);
|
|
111
|
+
const isForbidden = isListed(answer.address, answer.family, blockedAddresses)
|
|
112
|
+
|| isIpv4Compatible
|
|
113
|
+
|| (isMappedIpv4 && !isLoopback);
|
|
114
|
+
if ((isForbidden && !isLoopback) || (isLoopback && !options.allowLocalNetwork)) {
|
|
115
|
+
throw new RemoteNetworkPolicyError("REMOTE_TARGET_FORBIDDEN", "Remote target resolved to a forbidden address.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const selected = normalizedAnswers[0];
|
|
119
|
+
return {
|
|
120
|
+
hostname,
|
|
121
|
+
address: selected.address,
|
|
122
|
+
family: selected.family,
|
|
123
|
+
local: isListed(selected.address, selected.family, loopbackAddresses)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js";
|
|
2
|
+
import type { Finding } from "../domain/types.js";
|
|
3
|
+
export type RemoteOAuthReadinessRequest = (rawUrl: string, options?: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
|
|
4
|
+
export interface RemoteOAuthReadinessOptions {
|
|
5
|
+
request?: RemoteOAuthReadinessRequest;
|
|
6
|
+
requestOptions?: Pick<BoundedHttpRequestOptions, "allowLocalNetwork" | "lookup" | "timeoutMs">;
|
|
7
|
+
}
|
|
8
|
+
export interface RemoteOAuthReadinessResult {
|
|
9
|
+
status: "pass" | "fail";
|
|
10
|
+
findings: Finding[];
|
|
11
|
+
}
|
|
12
|
+
export declare function checkRemoteOAuthReadiness(resourceUrl: string, wwwAuthenticate: Array<string | string[]>, options?: RemoteOAuthReadinessOptions): Promise<RemoteOAuthReadinessResult>;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { requestBoundedHttp } from "./bounded-http-client.js";
|
|
3
|
+
const MAX_DISCOVERY_CANDIDATES = 4;
|
|
4
|
+
const MAX_DISCOVERY_TIMEOUT_MS = 3_000;
|
|
5
|
+
function failure(id) {
|
|
6
|
+
return {
|
|
7
|
+
status: "fail",
|
|
8
|
+
findings: [{
|
|
9
|
+
id,
|
|
10
|
+
severity: "fail",
|
|
11
|
+
message: "The remote MCP server authorization metadata could not be validated.",
|
|
12
|
+
impact: "Protected MCP endpoints cannot be safely assessed without valid OAuth discovery metadata.",
|
|
13
|
+
suggestedFix: "Publish valid HTTPS protected-resource and authorization-server metadata without credentials, queries, or fragments."
|
|
14
|
+
}]
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function mediaType(headers) {
|
|
21
|
+
const value = headers["content-type"];
|
|
22
|
+
const source = Array.isArray(value) ? value[0] : value;
|
|
23
|
+
return typeof source === "string" ? source.split(";", 1)[0]?.trim().toLowerCase() ?? null : null;
|
|
24
|
+
}
|
|
25
|
+
function parseJsonObject(body) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(body.toString("utf8"));
|
|
28
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function safeHttpsUrl(value) {
|
|
35
|
+
if (typeof value !== "string")
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(value);
|
|
39
|
+
if (url.protocol !== "https:" ||
|
|
40
|
+
!url.hostname ||
|
|
41
|
+
url.username ||
|
|
42
|
+
url.password ||
|
|
43
|
+
url.search ||
|
|
44
|
+
url.hash ||
|
|
45
|
+
url.hostname.startsWith("[") ||
|
|
46
|
+
isIP(url.hostname) !== 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function insertWellKnown(resourceUrl, suffix) {
|
|
56
|
+
const url = new URL(resourceUrl);
|
|
57
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
|
58
|
+
return `${url.origin}/.well-known/${suffix}${path}`;
|
|
59
|
+
}
|
|
60
|
+
function oidcDiscoveryUrl(issuer) {
|
|
61
|
+
const url = new URL(issuer);
|
|
62
|
+
return `${url.origin}${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
63
|
+
}
|
|
64
|
+
function splitTopLevelCommas(value) {
|
|
65
|
+
const parts = [];
|
|
66
|
+
let start = 0;
|
|
67
|
+
let quoted = false;
|
|
68
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
69
|
+
const character = value[index];
|
|
70
|
+
if (quoted && character === "\\") {
|
|
71
|
+
index += 1;
|
|
72
|
+
if (index >= value.length)
|
|
73
|
+
return null;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (character === "\"") {
|
|
77
|
+
quoted = !quoted;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!quoted && character === ",") {
|
|
81
|
+
parts.push(value.slice(start, index));
|
|
82
|
+
start = index + 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return quoted ? null : [...parts, value.slice(start)];
|
|
86
|
+
}
|
|
87
|
+
function parseAuthParam(value) {
|
|
88
|
+
const match = /^([!#$%&'*+.^`|~\w-]+)[ \t]*=[ \t]*(.*)$/.exec(value);
|
|
89
|
+
if (match === null)
|
|
90
|
+
return null;
|
|
91
|
+
const name = match[1];
|
|
92
|
+
const rawValue = match[2].trim();
|
|
93
|
+
if (rawValue.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
if (!rawValue.startsWith("\"")) {
|
|
96
|
+
return /[\s"]/u.test(rawValue) ? null : { name, value: rawValue };
|
|
97
|
+
}
|
|
98
|
+
if (!rawValue.endsWith("\"") || rawValue.length === 1)
|
|
99
|
+
return null;
|
|
100
|
+
let decoded = "";
|
|
101
|
+
for (let index = 1; index < rawValue.length - 1; index += 1) {
|
|
102
|
+
const character = rawValue[index];
|
|
103
|
+
if (character === "\\") {
|
|
104
|
+
index += 1;
|
|
105
|
+
if (index >= rawValue.length - 1)
|
|
106
|
+
return null;
|
|
107
|
+
decoded += rawValue[index];
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (character === "\"")
|
|
111
|
+
return null;
|
|
112
|
+
decoded += character;
|
|
113
|
+
}
|
|
114
|
+
return { name, value: decoded };
|
|
115
|
+
}
|
|
116
|
+
function parseChallenge(value) {
|
|
117
|
+
const match = /^([!#$%&'*+.^`|~\w-]+)(?:[ \t]+(.*))?$/.exec(value);
|
|
118
|
+
if (match === null)
|
|
119
|
+
return null;
|
|
120
|
+
const scheme = match[1];
|
|
121
|
+
const credentials = match[2];
|
|
122
|
+
if (credentials === undefined || credentials.trim().length === 0)
|
|
123
|
+
return { scheme, parameter: null };
|
|
124
|
+
const parameter = parseAuthParam(credentials);
|
|
125
|
+
if (parameter !== null)
|
|
126
|
+
return { scheme, parameter };
|
|
127
|
+
return /^[A-Za-z0-9\-._~+/]+={0,}$/.test(credentials) ? { scheme, parameter: null } : null;
|
|
128
|
+
}
|
|
129
|
+
function parseBearerResourceMetadata(headers) {
|
|
130
|
+
const values = [];
|
|
131
|
+
let specified = false;
|
|
132
|
+
for (const header of headers.flatMap((value) => Array.isArray(value) ? value : [value])) {
|
|
133
|
+
const parts = splitTopLevelCommas(header);
|
|
134
|
+
if (parts === null)
|
|
135
|
+
return null;
|
|
136
|
+
let scheme = null;
|
|
137
|
+
for (const part of parts) {
|
|
138
|
+
const token = part.trim();
|
|
139
|
+
if (token.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
const parameter = parseAuthParam(token);
|
|
142
|
+
if (parameter !== null) {
|
|
143
|
+
if (scheme === null)
|
|
144
|
+
return null;
|
|
145
|
+
if (scheme.toLowerCase() === "bearer" && parameter.name.toLowerCase() === "resource_metadata") {
|
|
146
|
+
specified = true;
|
|
147
|
+
values.push(parameter.value);
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const challenge = parseChallenge(token);
|
|
152
|
+
if (challenge === null)
|
|
153
|
+
return null;
|
|
154
|
+
scheme = challenge.scheme;
|
|
155
|
+
if (scheme.toLowerCase() === "bearer" && challenge.parameter?.name.toLowerCase() === "resource_metadata") {
|
|
156
|
+
specified = true;
|
|
157
|
+
values.push(challenge.parameter.value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { specified, values };
|
|
162
|
+
}
|
|
163
|
+
async function fetchMetadata(url, request, requestOptions, deadline) {
|
|
164
|
+
const remainingMs = deadline - Date.now();
|
|
165
|
+
if (remainingMs <= 0)
|
|
166
|
+
return { kind: "unavailable" };
|
|
167
|
+
let timeout;
|
|
168
|
+
try {
|
|
169
|
+
const response = await Promise.race([
|
|
170
|
+
request(url, {
|
|
171
|
+
...requestOptions,
|
|
172
|
+
timeoutMs: Math.max(1, Math.floor(remainingMs)),
|
|
173
|
+
method: "GET",
|
|
174
|
+
headers: { Accept: "application/json" }
|
|
175
|
+
}),
|
|
176
|
+
new Promise((_resolve, reject) => {
|
|
177
|
+
timeout = setTimeout(() => reject(new Error("OAuth discovery timed out.")), remainingMs);
|
|
178
|
+
})
|
|
179
|
+
]);
|
|
180
|
+
if (response.statusCode === 404)
|
|
181
|
+
return { kind: "not-found" };
|
|
182
|
+
if (response.statusCode !== 200 || mediaType(response.headers) !== "application/json") {
|
|
183
|
+
return { kind: "unavailable" };
|
|
184
|
+
}
|
|
185
|
+
const metadata = parseJsonObject(response.body);
|
|
186
|
+
return metadata === null ? { kind: "invalid" } : { kind: "ok", metadata };
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return { kind: "unavailable" };
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (timeout !== undefined)
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function validProtectedResourceMetadata(metadata, resourceUrl) {
|
|
197
|
+
if (metadata.resource !== resourceUrl || !Array.isArray(metadata.authorization_servers) || metadata.authorization_servers.length === 0) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const issuers = metadata.authorization_servers.map(safeHttpsUrl);
|
|
201
|
+
if (!issuers.every((issuer) => issuer !== null))
|
|
202
|
+
return null;
|
|
203
|
+
const uniqueIssuers = [...new Set(issuers)];
|
|
204
|
+
return uniqueIssuers.length <= MAX_DISCOVERY_CANDIDATES ? uniqueIssuers : null;
|
|
205
|
+
}
|
|
206
|
+
function validAuthorizationServerMetadata(metadata, issuer) {
|
|
207
|
+
return (metadata.issuer === issuer &&
|
|
208
|
+
safeHttpsUrl(metadata.authorization_endpoint) !== null &&
|
|
209
|
+
safeHttpsUrl(metadata.token_endpoint) !== null);
|
|
210
|
+
}
|
|
211
|
+
async function authorizationServerIsReady(issuer, request, requestOptions, deadline) {
|
|
212
|
+
const rfc8414 = await fetchMetadata(insertWellKnown(issuer, "oauth-authorization-server"), request, requestOptions, deadline);
|
|
213
|
+
if (rfc8414.kind === "ok")
|
|
214
|
+
return validAuthorizationServerMetadata(rfc8414.metadata, issuer) ? "pass" : "invalid";
|
|
215
|
+
if (rfc8414.kind === "unavailable")
|
|
216
|
+
return "unavailable";
|
|
217
|
+
const oidcUrls = [...new Set([
|
|
218
|
+
insertWellKnown(issuer, "openid-configuration"),
|
|
219
|
+
oidcDiscoveryUrl(issuer)
|
|
220
|
+
])];
|
|
221
|
+
for (const oidcUrl of oidcUrls) {
|
|
222
|
+
const oidc = await fetchMetadata(oidcUrl, request, requestOptions, deadline);
|
|
223
|
+
if (oidc.kind === "ok")
|
|
224
|
+
return validAuthorizationServerMetadata(oidc.metadata, issuer) ? "pass" : "invalid";
|
|
225
|
+
if (oidc.kind === "unavailable")
|
|
226
|
+
return "unavailable";
|
|
227
|
+
}
|
|
228
|
+
return "invalid";
|
|
229
|
+
}
|
|
230
|
+
export async function checkRemoteOAuthReadiness(resourceUrl, wwwAuthenticate, options = {}) {
|
|
231
|
+
const request = options.request ?? requestBoundedHttp;
|
|
232
|
+
const explicit = parseBearerResourceMetadata(wwwAuthenticate);
|
|
233
|
+
if (explicit === null)
|
|
234
|
+
return failure("plugin.runtime.remote.authorization.metadata.invalid");
|
|
235
|
+
const metadataCandidates = explicit.specified
|
|
236
|
+
? explicit.values.map(safeHttpsUrl)
|
|
237
|
+
: [
|
|
238
|
+
insertWellKnown(resourceUrl, "oauth-protected-resource"),
|
|
239
|
+
`${new URL(resourceUrl).origin}/.well-known/oauth-protected-resource`
|
|
240
|
+
].map(safeHttpsUrl);
|
|
241
|
+
if (!metadataCandidates.every((url) => url !== null)) {
|
|
242
|
+
return failure("plugin.runtime.remote.authorization.metadata.invalid");
|
|
243
|
+
}
|
|
244
|
+
const metadataUrls = [...new Set(metadataCandidates)];
|
|
245
|
+
if (metadataUrls.length > MAX_DISCOVERY_CANDIDATES)
|
|
246
|
+
return failure("plugin.runtime.remote.authorization.metadata.invalid");
|
|
247
|
+
const suppliedTimeoutMs = options.requestOptions?.timeoutMs;
|
|
248
|
+
const timeoutMs = typeof suppliedTimeoutMs === "number" && Number.isFinite(suppliedTimeoutMs)
|
|
249
|
+
? Math.max(0, Math.min(suppliedTimeoutMs, MAX_DISCOVERY_TIMEOUT_MS))
|
|
250
|
+
: MAX_DISCOVERY_TIMEOUT_MS;
|
|
251
|
+
const deadline = Date.now() + timeoutMs;
|
|
252
|
+
let issuers = null;
|
|
253
|
+
let protectedMetadataFetchedInvalid = false;
|
|
254
|
+
let protectedMetadataUnavailable = false;
|
|
255
|
+
for (const metadataUrl of metadataUrls) {
|
|
256
|
+
const reply = await fetchMetadata(metadataUrl, request, options.requestOptions, deadline);
|
|
257
|
+
if (reply.kind === "ok") {
|
|
258
|
+
const candidateIssuers = validProtectedResourceMetadata(reply.metadata, resourceUrl);
|
|
259
|
+
if (candidateIssuers !== null) {
|
|
260
|
+
issuers = candidateIssuers;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
protectedMetadataFetchedInvalid = true;
|
|
264
|
+
if (!explicit.specified)
|
|
265
|
+
return failure("plugin.runtime.remote.authorization.metadata.invalid");
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (reply.kind === "invalid") {
|
|
269
|
+
protectedMetadataFetchedInvalid = true;
|
|
270
|
+
if (!explicit.specified)
|
|
271
|
+
return failure("plugin.runtime.remote.authorization.metadata.invalid");
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
protectedMetadataUnavailable = true;
|
|
275
|
+
if (!explicit.specified && reply.kind === "unavailable") {
|
|
276
|
+
return failure("plugin.runtime.remote.authorization.metadata.unavailable");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (issuers === null)
|
|
280
|
+
return failure(protectedMetadataFetchedInvalid
|
|
281
|
+
? "plugin.runtime.remote.authorization.metadata.invalid"
|
|
282
|
+
: protectedMetadataUnavailable
|
|
283
|
+
? "plugin.runtime.remote.authorization.metadata.unavailable"
|
|
284
|
+
: "plugin.runtime.remote.authorization.metadata.invalid");
|
|
285
|
+
let unavailable = false;
|
|
286
|
+
for (const issuer of issuers) {
|
|
287
|
+
const readiness = await authorizationServerIsReady(issuer, request, options.requestOptions, deadline);
|
|
288
|
+
if (readiness === "pass")
|
|
289
|
+
return { status: "pass", findings: [] };
|
|
290
|
+
unavailable ||= readiness === "unavailable";
|
|
291
|
+
}
|
|
292
|
+
return failure(unavailable
|
|
293
|
+
? "plugin.runtime.remote.authorization.metadata.unavailable"
|
|
294
|
+
: "plugin.runtime.remote.authorization.metadata.invalid");
|
|
295
|
+
}
|