codex-plugin-doctor 1.51.0 → 1.53.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 -2
- package/dist/core/bounded-http-client.d.ts +23 -0
- package/dist/core/bounded-http-client.js +241 -0
- package/dist/core/doctor-export-bundle.js +3 -1
- package/dist/core/output-contract.js +50 -1
- package/dist/core/release-check.d.ts +4 -0
- package/dist/core/release-check.js +4 -0
- package/dist/core/release-evidence.d.ts +4 -0
- package/dist/core/release-evidence.js +113 -7
- package/dist/core/remote-mcp-probe.d.ts +17 -0
- package/dist/core/remote-mcp-probe.js +284 -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-transport-reliability.d.ts +18 -0
- package/dist/core/remote-transport-reliability.js +243 -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 +10 -1
- package/dist/core/runtime-probe.js +89 -29
- package/dist/core/sse-observation.d.ts +7 -0
- package/dist/core/sse-observation.js +67 -0
- package/dist/core/validate-plugin.js +35 -3
- package/dist/domain/types.d.ts +26 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +4 -0
- package/dist/mcp/generic-mcp-doctor.js +15 -5
- package/dist/reporting/render-markdown-report.js +38 -12
- package/dist/reporting/render-text-report.js +32 -10
- package/dist/rules/rule-catalog.js +289 -1
- package/dist/run-cli.js +172 -5
- package/dist/security/security-audit.js +34 -7
- package/package.json +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
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 { probeRemoteTransportReliability } from "./remote-transport-reliability.js";
|
|
5
|
+
import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
|
|
6
|
+
import { packageVersion } from "../version.js";
|
|
7
|
+
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
8
|
+
function createScorecard() {
|
|
9
|
+
return {
|
|
10
|
+
transport: "skipped",
|
|
11
|
+
networkSafety: "skipped",
|
|
12
|
+
initialize: "skipped",
|
|
13
|
+
contentType: "skipped",
|
|
14
|
+
session: "absent",
|
|
15
|
+
protocolHeaders: "skipped",
|
|
16
|
+
authorization: "skipped",
|
|
17
|
+
overall: "skipped"
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function failure(id, message, impact, suggestedFix) {
|
|
21
|
+
return { id, severity: "fail", message, impact, suggestedFix };
|
|
22
|
+
}
|
|
23
|
+
function finalize(scorecard, findings) {
|
|
24
|
+
scorecard.overall = findings.some((finding) => finding.severity === "fail")
|
|
25
|
+
? "fail"
|
|
26
|
+
: findings.some((finding) => finding.severity === "warn")
|
|
27
|
+
? "warn"
|
|
28
|
+
: scorecard.initialize === "pass" || scorecard.authorization === "pass"
|
|
29
|
+
? "pass"
|
|
30
|
+
: "skipped";
|
|
31
|
+
return { findings, scorecard };
|
|
32
|
+
}
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function responseHeader(headers, name) {
|
|
37
|
+
const value = headers[name];
|
|
38
|
+
return typeof value === "string" ? value : null;
|
|
39
|
+
}
|
|
40
|
+
function mediaType(value) {
|
|
41
|
+
return value?.split(";", 1)[0]?.trim().toLowerCase() ?? null;
|
|
42
|
+
}
|
|
43
|
+
function parseJsonObject(source) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(source);
|
|
46
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isInitializeResponseCandidate(message) {
|
|
53
|
+
return message.id === 1;
|
|
54
|
+
}
|
|
55
|
+
function findSseInitializeResponse(body) {
|
|
56
|
+
const text = body.toString("utf8").replace(/\r\n/g, "\n");
|
|
57
|
+
let offset = 0;
|
|
58
|
+
while (offset < text.length) {
|
|
59
|
+
const boundary = text.indexOf("\n\n", offset);
|
|
60
|
+
if (boundary === -1) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const data = text.slice(offset, boundary).split("\n")
|
|
64
|
+
.filter((line) => line.startsWith("data:"))
|
|
65
|
+
.map((line) => line.slice(5).replace(/^ /, ""));
|
|
66
|
+
const message = data.length > 0 ? parseJsonObject(data.join("\n")) : null;
|
|
67
|
+
if (message && isInitializeResponseCandidate(message)) {
|
|
68
|
+
return message;
|
|
69
|
+
}
|
|
70
|
+
offset = boundary + 2;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function parseInitializeResponse(body, contentType) {
|
|
75
|
+
return contentType === "text/event-stream"
|
|
76
|
+
? findSseInitializeResponse(body)
|
|
77
|
+
: parseJsonObject(body.toString("utf8"));
|
|
78
|
+
}
|
|
79
|
+
function isValidInitializeResponse(message) {
|
|
80
|
+
return (message.jsonrpc === "2.0" &&
|
|
81
|
+
message.id === 1 &&
|
|
82
|
+
isPlainObject(message.result) &&
|
|
83
|
+
message.result.protocolVersion === MCP_PROTOCOL_VERSION &&
|
|
84
|
+
isPlainObject(message.result.capabilities) &&
|
|
85
|
+
isPlainObject(message.result.serverInfo) &&
|
|
86
|
+
typeof message.result.serverInfo.name === "string" &&
|
|
87
|
+
typeof message.result.serverInfo.version === "string");
|
|
88
|
+
}
|
|
89
|
+
function isInitializedNotificationAcknowledged(response) {
|
|
90
|
+
return response.statusCode === 202 && response.body.length === 0;
|
|
91
|
+
}
|
|
92
|
+
function validSessionId(value) {
|
|
93
|
+
return value !== null && /^[\x21-\x7e]+$/.test(value);
|
|
94
|
+
}
|
|
95
|
+
function transportFailureId(error) {
|
|
96
|
+
if (error instanceof BoundedHttpError) {
|
|
97
|
+
if (error.code === "REMOTE_HTTP_TIMEOUT")
|
|
98
|
+
return "plugin.runtime.remote.transport.timeout";
|
|
99
|
+
if (error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE")
|
|
100
|
+
return "plugin.runtime.remote.transport.response_too_large";
|
|
101
|
+
}
|
|
102
|
+
return "plugin.runtime.remote.transport.failed";
|
|
103
|
+
}
|
|
104
|
+
function transportStatus(error) {
|
|
105
|
+
return error instanceof RemoteNetworkPolicyError
|
|
106
|
+
? "skipped"
|
|
107
|
+
: "fail";
|
|
108
|
+
}
|
|
109
|
+
export async function probeRemoteMcpServer(serverName, rawUrl, options = {}) {
|
|
110
|
+
const scorecard = createScorecard();
|
|
111
|
+
const findings = [];
|
|
112
|
+
const request = options.request ?? requestBoundedHttp;
|
|
113
|
+
if (!options.allowNetwork) {
|
|
114
|
+
scorecard.networkSafety = "fail";
|
|
115
|
+
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."));
|
|
116
|
+
return finalize(scorecard, findings);
|
|
117
|
+
}
|
|
118
|
+
const inspection = inspectRemoteMcpUrl(rawUrl);
|
|
119
|
+
if (inspection.issues.length > 0) {
|
|
120
|
+
scorecard.networkSafety = "fail";
|
|
121
|
+
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."));
|
|
122
|
+
return finalize(scorecard, findings);
|
|
123
|
+
}
|
|
124
|
+
scorecard.networkSafety = "pass";
|
|
125
|
+
const initializeBody = JSON.stringify({
|
|
126
|
+
jsonrpc: "2.0",
|
|
127
|
+
id: 1,
|
|
128
|
+
method: "initialize",
|
|
129
|
+
params: {
|
|
130
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
131
|
+
capabilities: {},
|
|
132
|
+
clientInfo: { name: "Codex Plugin Doctor", version: packageVersion }
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
let initializeResponse;
|
|
136
|
+
try {
|
|
137
|
+
initializeResponse = await request(rawUrl, {
|
|
138
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
139
|
+
lookup: options.lookup,
|
|
140
|
+
timeoutMs: options.requestTimeoutMs,
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: initializeBody,
|
|
143
|
+
headers: {
|
|
144
|
+
Accept: "application/json, text/event-stream",
|
|
145
|
+
"Content-Type": "application/json"
|
|
146
|
+
},
|
|
147
|
+
stopAfter: (body) => findSseInitializeResponse(body) !== null
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
scorecard.transport = transportStatus(error);
|
|
152
|
+
if (error instanceof RemoteNetworkPolicyError) {
|
|
153
|
+
scorecard.networkSafety = "fail";
|
|
154
|
+
}
|
|
155
|
+
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."));
|
|
156
|
+
return finalize(scorecard, findings);
|
|
157
|
+
}
|
|
158
|
+
scorecard.transport = "pass";
|
|
159
|
+
if (initializeResponse.statusCode === 401) {
|
|
160
|
+
const challenge = initializeResponse.headers["www-authenticate"];
|
|
161
|
+
const readiness = await checkRemoteOAuthReadiness(inspection.sanitizedUrl ?? rawUrl, challenge === undefined ? [] : [challenge], {
|
|
162
|
+
request,
|
|
163
|
+
requestOptions: {
|
|
164
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
165
|
+
lookup: options.lookup,
|
|
166
|
+
timeoutMs: options.requestTimeoutMs
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
scorecard.authorization = readiness.status;
|
|
170
|
+
findings.push(...readiness.findings);
|
|
171
|
+
return finalize(scorecard, findings);
|
|
172
|
+
}
|
|
173
|
+
if (initializeResponse.statusCode !== 200) {
|
|
174
|
+
scorecard.initialize = "fail";
|
|
175
|
+
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."));
|
|
176
|
+
return finalize(scorecard, findings);
|
|
177
|
+
}
|
|
178
|
+
const contentType = mediaType(responseHeader(initializeResponse.headers, "content-type"));
|
|
179
|
+
if (contentType !== "application/json" && contentType !== "text/event-stream") {
|
|
180
|
+
scorecard.contentType = "fail";
|
|
181
|
+
scorecard.initialize = "fail";
|
|
182
|
+
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."));
|
|
183
|
+
return finalize(scorecard, findings);
|
|
184
|
+
}
|
|
185
|
+
scorecard.contentType = "pass";
|
|
186
|
+
const sessionId = responseHeader(initializeResponse.headers, "mcp-session-id");
|
|
187
|
+
if (sessionId !== null && !validSessionId(sessionId)) {
|
|
188
|
+
scorecard.session = "present-invalid";
|
|
189
|
+
scorecard.initialize = "fail";
|
|
190
|
+
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."));
|
|
191
|
+
return finalize(scorecard, findings);
|
|
192
|
+
}
|
|
193
|
+
if (sessionId !== null) {
|
|
194
|
+
scorecard.session = "present-valid";
|
|
195
|
+
}
|
|
196
|
+
const initializeMessage = parseInitializeResponse(initializeResponse.body, contentType);
|
|
197
|
+
if (!initializeMessage || !isValidInitializeResponse(initializeMessage)) {
|
|
198
|
+
scorecard.initialize = "fail";
|
|
199
|
+
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."));
|
|
200
|
+
return finalize(scorecard, findings);
|
|
201
|
+
}
|
|
202
|
+
scorecard.initialize = "pass";
|
|
203
|
+
try {
|
|
204
|
+
const initializedResponse = await request(rawUrl, {
|
|
205
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
206
|
+
lookup: options.lookup,
|
|
207
|
+
timeoutMs: options.requestTimeoutMs,
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
210
|
+
headers: {
|
|
211
|
+
Accept: "application/json, text/event-stream",
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
214
|
+
...(sessionId === null ? {} : { "MCP-Session-Id": sessionId })
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (!isInitializedNotificationAcknowledged(initializedResponse)) {
|
|
218
|
+
scorecard.protocolHeaders = "fail";
|
|
219
|
+
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."));
|
|
220
|
+
return finalize(scorecard, findings);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
scorecard.protocolHeaders = "fail";
|
|
225
|
+
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."));
|
|
226
|
+
return finalize(scorecard, findings);
|
|
227
|
+
}
|
|
228
|
+
scorecard.protocolHeaders = "pass";
|
|
229
|
+
const reliability = await probeRemoteTransportReliability({
|
|
230
|
+
rawUrl,
|
|
231
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
232
|
+
request,
|
|
233
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
234
|
+
requestOptions: {
|
|
235
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
236
|
+
lookup: options.lookup
|
|
237
|
+
},
|
|
238
|
+
sessionId,
|
|
239
|
+
allowSessionLifecycle: options.allowSessionLifecycle,
|
|
240
|
+
reinitialize: async (requestWithinBudget) => {
|
|
241
|
+
const restartInitialize = await requestWithinBudget({
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: initializeBody,
|
|
244
|
+
headers: {
|
|
245
|
+
Accept: "application/json, text/event-stream",
|
|
246
|
+
"Content-Type": "application/json"
|
|
247
|
+
},
|
|
248
|
+
stopAfter: (body) => findSseInitializeResponse(body) !== null
|
|
249
|
+
});
|
|
250
|
+
if (restartInitialize.statusCode !== 200) {
|
|
251
|
+
throw new Error("restart initialize failed");
|
|
252
|
+
}
|
|
253
|
+
const restartContentType = mediaType(responseHeader(restartInitialize.headers, "content-type"));
|
|
254
|
+
if (restartContentType !== "application/json" && restartContentType !== "text/event-stream") {
|
|
255
|
+
throw new Error("restart initialize content type invalid");
|
|
256
|
+
}
|
|
257
|
+
const replacementSessionId = responseHeader(restartInitialize.headers, "mcp-session-id");
|
|
258
|
+
if (replacementSessionId !== null && !validSessionId(replacementSessionId)) {
|
|
259
|
+
throw new Error("restart session invalid");
|
|
260
|
+
}
|
|
261
|
+
const restartMessage = parseInitializeResponse(restartInitialize.body, restartContentType);
|
|
262
|
+
if (!restartMessage || !isValidInitializeResponse(restartMessage)) {
|
|
263
|
+
throw new Error("restart initialize result invalid");
|
|
264
|
+
}
|
|
265
|
+
const restartInitialized = await requestWithinBudget({
|
|
266
|
+
method: "POST",
|
|
267
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
268
|
+
headers: {
|
|
269
|
+
Accept: "application/json, text/event-stream",
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
272
|
+
...(replacementSessionId === null ? {} : { "MCP-Session-Id": replacementSessionId })
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
if (!isInitializedNotificationAcknowledged(restartInitialized)) {
|
|
276
|
+
throw new Error("restart initialized failed");
|
|
277
|
+
}
|
|
278
|
+
return replacementSessionId;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
scorecard.reliability = reliability.scorecard;
|
|
282
|
+
findings.push(...reliability.findings);
|
|
283
|
+
return finalize(scorecard, findings);
|
|
284
|
+
}
|
|
@@ -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>;
|