echelongraph-mcp 2.3.4 → 2.5.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/CHANGELOG.md +136 -0
- package/README.md +304 -46
- package/dist/http.js +240 -0
- package/dist/httpPolicy.js +244 -0
- package/dist/index.js +123 -46
- package/dist/prompts.js +137 -0
- package/dist/resources.js +153 -0
- package/dist/runtime.js +65 -0
- package/dist/tools/check_affected.js +269 -0
- package/dist/tools/check_sbom.js +329 -0
- package/dist/tools/cve_intel.js +276 -0
- package/dist/tools/epss_history.js +125 -0
- package/dist/tools/get_cwe.js +148 -0
- package/dist/tools/kev_recent.js +190 -0
- package/dist/tools/vendor_advisories.js +316 -0
- package/package.json +5 -2
- package/server.json +18 -3
package/dist/http.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The hosted, keyless remote MCP endpoint: https://mcp.echelongraph.io/mcp (#2316).
|
|
3
|
+
//
|
|
4
|
+
// Streamable HTTP on @modelcontextprotocol/server 2.x's createMcpHandler, serving the SAME tool
|
|
5
|
+
// module as the npm package (index.ts's createServer), in both protocol eras: a 2026-07-28
|
|
6
|
+
// client's per-request envelope (server/discover first), and a 2025-era client's initialize,
|
|
7
|
+
// answered statelessly by the SDK's legacy fallback. Nothing is kept between requests: each one
|
|
8
|
+
// gets a fresh server from the factory.
|
|
9
|
+
//
|
|
10
|
+
// POST /mcp the MCP endpoint (GET and DELETE are the 2025 session operations; stateless,
|
|
11
|
+
// the SDK answers them 405)
|
|
12
|
+
// OPTIONS /mcp CORS preflight, for a browser client on an https Origin
|
|
13
|
+
// GET /health liveness. Not /healthz: Cloud Run's front end reserves paths ending in "z"
|
|
14
|
+
// and answers them itself, so a /healthz would never reach this process.
|
|
15
|
+
//
|
|
16
|
+
// No auth: the endpoint serves only public data (MCP makes authorization optional), and the
|
|
17
|
+
// server's instructions say what it is. The edge rules — Origin policy, per-client limit, body
|
|
18
|
+
// cap, what is logged — are in httpPolicy.ts, each with its reasoning.
|
|
19
|
+
//
|
|
20
|
+
// The API sees every remote user as the client it is, not as this service: each call carries the
|
|
21
|
+
// client's address and a forward token core-backend checks (runtime.ts; core-backend
|
|
22
|
+
// waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
|
|
23
|
+
// token fatal at start-up, because without it every remote user would share one API budget.
|
|
24
|
+
import http from "node:http";
|
|
25
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
26
|
+
import { forwardToken, markHttpEntrypoint, withClient } from "./runtime.js";
|
|
27
|
+
import { BODY_TOO_LARGE_CODE, DEFAULT_RATE_LIMIT_PER_MIN, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, uaFamily, } from "./httpPolicy.js";
|
|
28
|
+
// Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
|
|
29
|
+
markHttpEntrypoint();
|
|
30
|
+
const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
|
|
31
|
+
const positiveInt = (v, fallback) => {
|
|
32
|
+
const n = Number(v);
|
|
33
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
34
|
+
};
|
|
35
|
+
const PORT = (() => {
|
|
36
|
+
const n = Number(process.env.PORT);
|
|
37
|
+
return Number.isInteger(n) && n >= 0 && n < 65536 && process.env.PORT !== "" && process.env.PORT !== undefined ? n : 8080;
|
|
38
|
+
})();
|
|
39
|
+
const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAULT_RATE_LIMIT_PER_MIN);
|
|
40
|
+
// Tool arguments are a CVE ID or a short search; 64 KiB is far above any real request and far
|
|
41
|
+
// below the SDK's 4 MiB default.
|
|
42
|
+
const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, 64 * 1024);
|
|
43
|
+
const MCP_PATH = "/mcp";
|
|
44
|
+
const HEALTH_PATH = "/health";
|
|
45
|
+
// One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
|
|
46
|
+
function log(severity, message, fields = {}) {
|
|
47
|
+
process.stdout.write(`${JSON.stringify({ severity, message, ...fields })}\n`);
|
|
48
|
+
}
|
|
49
|
+
if (process.env.MCP_REQUIRE_FORWARD_TOKEN === "1" && !forwardToken()) {
|
|
50
|
+
log("ERROR", "mcp_remote_refusing_to_start", {
|
|
51
|
+
reason: "MCP_REQUIRE_FORWARD_TOKEN=1 but ECHELONGRAPH_FORWARD_TOKEN is empty: the API would count every remote user as this one service (#2212)",
|
|
52
|
+
});
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const handler = createMcpHandler(createServer, {
|
|
56
|
+
legacy: "stateless",
|
|
57
|
+
// responseMode stays the SDK default, "auto": a modern request is answered with one JSON body
|
|
58
|
+
// unless a handler emits something before its result (none of these tools does). The legacy
|
|
59
|
+
// leg answers over SSE, as a 2025-era streamable HTTP server does.
|
|
60
|
+
maxRequestBodySize: MAX_BODY_BYTES,
|
|
61
|
+
// Names only: an SDK error message can quote what the client sent.
|
|
62
|
+
onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name }),
|
|
63
|
+
});
|
|
64
|
+
const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
|
|
65
|
+
// CORS for a browser client on an https Origin (the Origin policy has already admitted it).
|
|
66
|
+
// A wildcard is safe here because nothing is credentialed: no cookies, no auth.
|
|
67
|
+
const CORS_HEADERS = {
|
|
68
|
+
"Access-Control-Allow-Origin": "*",
|
|
69
|
+
"Access-Control-Expose-Headers": "Mcp-Session-Id, MCP-Protocol-Version, Retry-After",
|
|
70
|
+
};
|
|
71
|
+
const PREFLIGHT_HEADERS = {
|
|
72
|
+
...CORS_HEADERS,
|
|
73
|
+
"Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
|
|
74
|
+
"Access-Control-Allow-Headers": "Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID",
|
|
75
|
+
"Access-Control-Max-Age": "600",
|
|
76
|
+
};
|
|
77
|
+
const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "expect"]);
|
|
78
|
+
function send(res, status, body, headers = {}) {
|
|
79
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
80
|
+
res.end(body);
|
|
81
|
+
}
|
|
82
|
+
// Reads the body up to MAX_BODY_BYTES. Over the cap: "too_large", and the rest is discarded
|
|
83
|
+
// unread (the response closes the connection).
|
|
84
|
+
function readBody(req) {
|
|
85
|
+
const declared = Number(req.headers["content-length"]);
|
|
86
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
87
|
+
req.resume();
|
|
88
|
+
return Promise.resolve("too_large");
|
|
89
|
+
}
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
let done = false;
|
|
94
|
+
req.on("data", (chunk) => {
|
|
95
|
+
if (done)
|
|
96
|
+
return;
|
|
97
|
+
size += chunk.length;
|
|
98
|
+
if (size > MAX_BODY_BYTES) {
|
|
99
|
+
done = true;
|
|
100
|
+
resolve("too_large");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
chunks.push(chunk);
|
|
104
|
+
});
|
|
105
|
+
req.on("end", () => {
|
|
106
|
+
if (!done)
|
|
107
|
+
resolve(Buffer.concat(chunks));
|
|
108
|
+
});
|
|
109
|
+
req.on("error", (e) => {
|
|
110
|
+
if (!done)
|
|
111
|
+
reject(e);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function serveMcp(req, res, pathname) {
|
|
116
|
+
const method = req.method ?? "GET";
|
|
117
|
+
const origin = req.headers.origin;
|
|
118
|
+
const verdict = originVerdict(Array.isArray(origin) ? origin.join(", ") : origin);
|
|
119
|
+
if (!verdict.ok) {
|
|
120
|
+
send(res, 403, rpcError(ORIGIN_REFUSED_CODE, `Origin refused: ${verdict.reason}. This endpoint serves requests with no Origin or an https Origin.`));
|
|
121
|
+
return { status: 403, refused: "origin" };
|
|
122
|
+
}
|
|
123
|
+
const cors = origin === undefined ? {} : CORS_HEADERS;
|
|
124
|
+
if (method === "OPTIONS") {
|
|
125
|
+
res.writeHead(204, PREFLIGHT_HEADERS);
|
|
126
|
+
res.end();
|
|
127
|
+
return { status: 204 };
|
|
128
|
+
}
|
|
129
|
+
const xff = req.headers["x-forwarded-for"];
|
|
130
|
+
const client = resolveClient(Array.isArray(xff) ? xff.join(",") : xff, req.socket.remoteAddress);
|
|
131
|
+
const hit = limiter.hit(client.key);
|
|
132
|
+
if (!hit.allowed) {
|
|
133
|
+
send(res, 429, rpcError(RATE_LIMITED_CODE, `Rate limit exceeded: at most ${RATE_LIMIT_PER_MIN} requests per minute per client address. Retry after ${hit.retryAfterSec} s.`, { limit: RATE_LIMIT_PER_MIN, window_seconds: 60, retry_after_seconds: hit.retryAfterSec }), { ...cors, "Retry-After": String(hit.retryAfterSec) });
|
|
134
|
+
log("WARNING", "mcp_rate_limited", { client: client.key, limit: RATE_LIMIT_PER_MIN });
|
|
135
|
+
return { status: 429, throttled: true };
|
|
136
|
+
}
|
|
137
|
+
let body;
|
|
138
|
+
if (method === "POST") {
|
|
139
|
+
const read = await readBody(req);
|
|
140
|
+
if (read === "too_large") {
|
|
141
|
+
send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes.`), { ...cors, Connection: "close" });
|
|
142
|
+
return { status: 413, refused: "body_size" };
|
|
143
|
+
}
|
|
144
|
+
body = read;
|
|
145
|
+
}
|
|
146
|
+
const headers = new Headers();
|
|
147
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
148
|
+
if (v === undefined || HOP_BY_HOP.has(k))
|
|
149
|
+
continue;
|
|
150
|
+
for (const one of Array.isArray(v) ? v : [v])
|
|
151
|
+
headers.append(k, one);
|
|
152
|
+
}
|
|
153
|
+
const abort = new AbortController();
|
|
154
|
+
res.on("close", () => abort.abort());
|
|
155
|
+
const request = new Request(`http://localhost${pathname}`, {
|
|
156
|
+
method,
|
|
157
|
+
headers,
|
|
158
|
+
body: body && body.length > 0 ? new Uint8Array(body) : undefined,
|
|
159
|
+
signal: abort.signal,
|
|
160
|
+
});
|
|
161
|
+
const response = await withClient(client.forwardAddress, () => handler.fetch(request));
|
|
162
|
+
const out = { ...cors };
|
|
163
|
+
response.headers.forEach((v, k) => {
|
|
164
|
+
out[k] = v;
|
|
165
|
+
});
|
|
166
|
+
res.writeHead(response.status, out);
|
|
167
|
+
if (response.body) {
|
|
168
|
+
for await (const chunk of response.body)
|
|
169
|
+
res.write(chunk);
|
|
170
|
+
}
|
|
171
|
+
res.end();
|
|
172
|
+
return { status: response.status, body };
|
|
173
|
+
}
|
|
174
|
+
const server = http.createServer((req, res) => {
|
|
175
|
+
const start = process.hrtime.bigint();
|
|
176
|
+
// The path only: a query string is never read, kept or logged.
|
|
177
|
+
let pathname = "/";
|
|
178
|
+
try {
|
|
179
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
pathname = "(unparseable)";
|
|
183
|
+
}
|
|
184
|
+
const method = req.method ?? "GET";
|
|
185
|
+
const finish = (o) => {
|
|
186
|
+
const facts = o.body ? bodyFacts(o.body) : { rpc_method: "(none)" };
|
|
187
|
+
log("INFO", "mcp_request", {
|
|
188
|
+
method,
|
|
189
|
+
path: pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)",
|
|
190
|
+
status: o.status,
|
|
191
|
+
duration_ms: Number((process.hrtime.bigint() - start) / 1000000n),
|
|
192
|
+
...facts,
|
|
193
|
+
protocol_version: revisionLabel(req.headers["mcp-protocol-version"]),
|
|
194
|
+
origin_present: req.headers.origin !== undefined,
|
|
195
|
+
body_bytes: o.body?.length ?? 0,
|
|
196
|
+
throttled: o.throttled === true,
|
|
197
|
+
refused: o.refused ?? "",
|
|
198
|
+
ua_family: uaFamily(req.headers["user-agent"]),
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
if (pathname === HEALTH_PATH) {
|
|
202
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
203
|
+
send(res, 405, JSON.stringify({ error: "method not allowed" }), { Allow: "GET, HEAD" });
|
|
204
|
+
return finish({ status: 405 });
|
|
205
|
+
}
|
|
206
|
+
send(res, 200, JSON.stringify({ status: "ok", name: NAME, version: VERSION }));
|
|
207
|
+
return finish({ status: 200 });
|
|
208
|
+
}
|
|
209
|
+
if (pathname !== MCP_PATH) {
|
|
210
|
+
send(res, 404, JSON.stringify({ error: "not found", mcp_endpoint: MCP_PATH }));
|
|
211
|
+
return finish({ status: 404 });
|
|
212
|
+
}
|
|
213
|
+
serveMcp(req, res, pathname).then(finish, (e) => {
|
|
214
|
+
log("ERROR", "mcp_request_failed", { error_name: e instanceof Error ? e.name : typeof e });
|
|
215
|
+
if (!res.headersSent)
|
|
216
|
+
send(res, 500, rpcError(-32603, "Internal error"));
|
|
217
|
+
else
|
|
218
|
+
res.end();
|
|
219
|
+
finish({ status: 500 });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
server.listen(PORT, () => {
|
|
223
|
+
const addr = server.address();
|
|
224
|
+
log("INFO", "mcp_remote_listening", {
|
|
225
|
+
port: typeof addr === "object" && addr ? addr.port : PORT,
|
|
226
|
+
name: NAME,
|
|
227
|
+
version: VERSION,
|
|
228
|
+
api_base: SHOWN_BASE,
|
|
229
|
+
api_timeout_ms: TIMEOUT_MS,
|
|
230
|
+
forward_token_configured: forwardToken() !== "",
|
|
231
|
+
rate_limit_per_min: RATE_LIMIT_PER_MIN,
|
|
232
|
+
max_body_bytes: MAX_BODY_BYTES,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
const shutdown = () => {
|
|
236
|
+
server.close();
|
|
237
|
+
void handler.close();
|
|
238
|
+
};
|
|
239
|
+
process.on("SIGTERM", shutdown);
|
|
240
|
+
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The hosted endpoint's edge rules (#2316), kept apart from the server wiring so each can be
|
|
2
|
+
// tested on its own: who the client is, which Origins are served, the per-client limit, and the
|
|
3
|
+
// JSON-RPC error bodies the edge answers with.
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
// ── JSON-RPC errors from the edge ──
|
|
6
|
+
//
|
|
7
|
+
// Every refusal at the edge is a JSON-RPC error object, never an HTML page and never a bare
|
|
8
|
+
// status: an MCP client parses the body, and a model shown an HTML error page has nothing to say
|
|
9
|
+
// to its user. id is null because the edge answers before it has read (or trusted) the body.
|
|
10
|
+
export const ORIGIN_REFUSED_CODE = -32000; // the SDK's own code for an Origin refusal (originValidationResponse)
|
|
11
|
+
export const RATE_LIMITED_CODE = -32029; // implementation-defined server error range (-32000..-32099); "429"
|
|
12
|
+
export const BODY_TOO_LARGE_CODE = -32600; // Invalid Request
|
|
13
|
+
export function rpcError(code, message, data) {
|
|
14
|
+
return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message, ...(data ? { data } : {}) } });
|
|
15
|
+
}
|
|
16
|
+
export function originVerdict(origin) {
|
|
17
|
+
if (origin === undefined)
|
|
18
|
+
return { ok: true };
|
|
19
|
+
const o = origin.trim();
|
|
20
|
+
if (o === "")
|
|
21
|
+
return { ok: false, reason: "an empty Origin header" };
|
|
22
|
+
if (o.toLowerCase() === "null")
|
|
23
|
+
return { ok: false, reason: 'the opaque Origin "null"' };
|
|
24
|
+
if (/[\s,]/.test(o))
|
|
25
|
+
return { ok: false, reason: "a malformed Origin header (not a single origin)" };
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(o);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { ok: false, reason: "a malformed Origin header" };
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol !== "https:")
|
|
34
|
+
return { ok: false, reason: "a non-https Origin" };
|
|
35
|
+
// A serialized origin is scheme://host[:port] and nothing else.
|
|
36
|
+
if (url.username || url.password || `${url.protocol}//${url.host}` !== o.toLowerCase()) {
|
|
37
|
+
return { ok: false, reason: "a malformed Origin header (not a bare origin)" };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
// The eight 16-bit words of an IPv6 literal that net.isIP has already accepted.
|
|
42
|
+
function expandIPv6(s) {
|
|
43
|
+
// A dotted IPv4 tail ("::ffff:203.0.113.7") is rewritten as its two words first.
|
|
44
|
+
const lastColon = s.lastIndexOf(":");
|
|
45
|
+
const v4 = s.slice(lastColon + 1);
|
|
46
|
+
if (v4.includes(".")) {
|
|
47
|
+
const o = v4.split(".").map(Number);
|
|
48
|
+
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
49
|
+
return undefined;
|
|
50
|
+
s = `${s.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
|
51
|
+
}
|
|
52
|
+
const parts = s.split("::");
|
|
53
|
+
if (parts.length > 2)
|
|
54
|
+
return undefined;
|
|
55
|
+
const head = parts[0] ? parts[0].split(":") : [];
|
|
56
|
+
const rest = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
|
|
57
|
+
const fill = parts.length === 2 ? 8 - head.length - rest.length : 0;
|
|
58
|
+
if (fill < 0 || (parts.length === 1 && head.length !== 8))
|
|
59
|
+
return undefined;
|
|
60
|
+
const words = [...head, ...Array(fill).fill("0"), ...rest].map((h) => parseInt(h, 16));
|
|
61
|
+
if (words.length !== 8 || words.some((w) => !Number.isInteger(w) || w < 0 || w > 0xffff))
|
|
62
|
+
return undefined;
|
|
63
|
+
return words;
|
|
64
|
+
}
|
|
65
|
+
/** One forwarded hop or socket address, parsed with a forwarded hop's tolerances; undefined if it is not an address. */
|
|
66
|
+
export function parseAddress(raw) {
|
|
67
|
+
if (!raw)
|
|
68
|
+
return undefined;
|
|
69
|
+
let s = raw.trim();
|
|
70
|
+
if (!s)
|
|
71
|
+
return undefined;
|
|
72
|
+
// "[2001:db8::1]:443" / "[2001:db8::1]" → the literal; "203.0.113.7:51234" → the address.
|
|
73
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(s);
|
|
74
|
+
if (bracketed)
|
|
75
|
+
s = bracketed[1];
|
|
76
|
+
else if (/^[\d.]+:\d+$/.test(s))
|
|
77
|
+
s = s.slice(0, s.lastIndexOf(":"));
|
|
78
|
+
const zone = s.indexOf("%");
|
|
79
|
+
if (zone >= 0)
|
|
80
|
+
s = s.slice(0, zone);
|
|
81
|
+
const fam = isIP(s);
|
|
82
|
+
if (fam === 4)
|
|
83
|
+
return { family: 4, octets: s.split(".").map(Number), text: s };
|
|
84
|
+
if (fam !== 6)
|
|
85
|
+
return undefined;
|
|
86
|
+
const h = expandIPv6(s.toLowerCase());
|
|
87
|
+
if (!h)
|
|
88
|
+
return undefined;
|
|
89
|
+
// IPv4-mapped (::ffff:a.b.c.d) is the IPv4 address.
|
|
90
|
+
if (h.slice(0, 5).every((w) => w === 0) && h[5] === 0xffff) {
|
|
91
|
+
const octets = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff];
|
|
92
|
+
return { family: 4, octets, text: octets.join(".") };
|
|
93
|
+
}
|
|
94
|
+
return { family: 6, hextets: h, text: s.toLowerCase() };
|
|
95
|
+
}
|
|
96
|
+
/** Whether a parsed address is one a client on the public internet could have arrived from (core-backend's publicUnicast). */
|
|
97
|
+
export function isPublic(a) {
|
|
98
|
+
if (a.family === 4) {
|
|
99
|
+
const [o0, o1] = a.octets;
|
|
100
|
+
if (o0 === 0 || o0 === 10 || o0 === 127 || o0 >= 224)
|
|
101
|
+
return false; // this-network, private, loopback, multicast/reserved/broadcast
|
|
102
|
+
if (o0 === 169 && o1 === 254)
|
|
103
|
+
return false; // link-local (the platform's front end)
|
|
104
|
+
if (o0 === 172 && o1 >= 16 && o1 <= 31)
|
|
105
|
+
return false;
|
|
106
|
+
if (o0 === 192 && o1 === 168)
|
|
107
|
+
return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
const h = a.hextets;
|
|
111
|
+
if (h.every((w) => w === 0))
|
|
112
|
+
return false; // ::
|
|
113
|
+
if (h.slice(0, 7).every((w) => w === 0) && h[7] === 1)
|
|
114
|
+
return false; // ::1
|
|
115
|
+
if ((h[0] & 0xffc0) === 0xfe80)
|
|
116
|
+
return false; // fe80::/10 link-local
|
|
117
|
+
if ((h[0] & 0xff00) === 0xff00)
|
|
118
|
+
return false; // ff00::/8 multicast
|
|
119
|
+
if ((h[0] & 0xfe00) === 0xfc00)
|
|
120
|
+
return false; // fc00::/7 unique local
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
function keyOf(a) {
|
|
124
|
+
if (a.family === 4)
|
|
125
|
+
return a.octets.join(".");
|
|
126
|
+
return `${a.hextets.slice(0, 4).map((w) => w.toString(16)).join(":")}::/64`;
|
|
127
|
+
}
|
|
128
|
+
/** The client of one request: see the section comment. */
|
|
129
|
+
export function resolveClient(xff, socketAddress) {
|
|
130
|
+
if (xff) {
|
|
131
|
+
const hops = xff.split(",");
|
|
132
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
133
|
+
const a = parseAddress(hops[i]);
|
|
134
|
+
if (a && isPublic(a))
|
|
135
|
+
return { forwardAddress: a.text, key: keyOf(a) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const peer = parseAddress(socketAddress);
|
|
139
|
+
if (!peer)
|
|
140
|
+
return { key: socketAddress || "(unknown)" };
|
|
141
|
+
return { forwardAddress: isPublic(peer) ? peer.text : undefined, key: isPublic(peer) ? keyOf(peer) : peer.text };
|
|
142
|
+
}
|
|
143
|
+
// ── Per-client limit ──
|
|
144
|
+
//
|
|
145
|
+
// A fixed one-minute window per client key, in this instance's memory. The default is derived
|
|
146
|
+
// from the public API's own per-client ceiling, not invented: core-backend's WAF admits 600
|
|
147
|
+
// requests a minute per client address on the API's base paths (waf.DefaultTierConfigs
|
|
148
|
+
// ["default"], 10 a second), and the costliest tool here, exposure_radar, makes up to five API
|
|
149
|
+
// calls per MCP request. 600 / 5 = 120 MCP requests a minute keeps one client's heaviest use
|
|
150
|
+
// inside the ceiling the API applies to it (core-backend keys this service's calls on the
|
|
151
|
+
// forwarded client, #2212), so a client meets THIS limit — a JSON-RPC error it can read — before
|
|
152
|
+
// the API's. It is not a tier: it grants nothing the API does not already grant one address.
|
|
153
|
+
//
|
|
154
|
+
// Counted per instance: with Cloud Run's max-instances at N, one client spread over every
|
|
155
|
+
// instance could make N times this many requests a minute here; the API's own ceiling, which
|
|
156
|
+
// is counted fleet-wide, still binds behind it.
|
|
157
|
+
export const DEFAULT_RATE_LIMIT_PER_MIN = 120;
|
|
158
|
+
export class FixedWindowLimiter {
|
|
159
|
+
max;
|
|
160
|
+
windowMs;
|
|
161
|
+
now;
|
|
162
|
+
maxKeys;
|
|
163
|
+
windows = new Map();
|
|
164
|
+
constructor(max, windowMs = 60_000, now = Date.now, maxKeys = 50_000) {
|
|
165
|
+
this.max = max;
|
|
166
|
+
this.windowMs = windowMs;
|
|
167
|
+
this.now = now;
|
|
168
|
+
this.maxKeys = maxKeys;
|
|
169
|
+
}
|
|
170
|
+
hit(key) {
|
|
171
|
+
const t = this.now();
|
|
172
|
+
let w = this.windows.get(key);
|
|
173
|
+
if (!w || t - w.start >= this.windowMs) {
|
|
174
|
+
if (!w && this.windows.size >= this.maxKeys)
|
|
175
|
+
this.sweep(t);
|
|
176
|
+
w = { start: t, count: 0 };
|
|
177
|
+
this.windows.set(key, w);
|
|
178
|
+
}
|
|
179
|
+
if (w.count >= this.max) {
|
|
180
|
+
return { allowed: false, retryAfterSec: Math.max(1, Math.ceil((w.start + this.windowMs - t) / 1000)) };
|
|
181
|
+
}
|
|
182
|
+
w.count++;
|
|
183
|
+
return { allowed: true, remaining: this.max - w.count };
|
|
184
|
+
}
|
|
185
|
+
// Drop expired windows; if every key is live (a flood of distinct addresses), drop the oldest
|
|
186
|
+
// half rather than grow without bound. Forgetting a key early can only admit, never refuse.
|
|
187
|
+
sweep(t) {
|
|
188
|
+
for (const [k, w] of this.windows)
|
|
189
|
+
if (t - w.start >= this.windowMs)
|
|
190
|
+
this.windows.delete(k);
|
|
191
|
+
if (this.windows.size < this.maxKeys)
|
|
192
|
+
return;
|
|
193
|
+
let drop = Math.floor(this.windows.size / 2);
|
|
194
|
+
for (const k of this.windows.keys()) {
|
|
195
|
+
if (drop-- <= 0)
|
|
196
|
+
break;
|
|
197
|
+
this.windows.delete(k);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ── Bounded access-log fields ──
|
|
202
|
+
//
|
|
203
|
+
// The access line carries no body, no query string, no argument and no client address: only
|
|
204
|
+
// values from small closed sets or shapes, so nothing a client types reaches the log (#1983).
|
|
205
|
+
const RPC_METHOD = /^[a-z][a-z/_]{0,39}$/i;
|
|
206
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
207
|
+
const REVISION = /^\d{4}-\d{2}-\d{2}$/;
|
|
208
|
+
const UA_FAMILY = /^[A-Za-z0-9._-]{1,40}$/;
|
|
209
|
+
/** The JSON-RPC method and, for tools/call, the tool name, shape-checked; never anything else from the body. */
|
|
210
|
+
export function bodyFacts(body) {
|
|
211
|
+
if (!body || body.length === 0)
|
|
212
|
+
return { rpc_method: "(none)" };
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return { rpc_method: "(unparseable)" };
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(parsed))
|
|
221
|
+
return { rpc_method: "(batch)" };
|
|
222
|
+
if (parsed === null || typeof parsed !== "object")
|
|
223
|
+
return { rpc_method: "(other)" };
|
|
224
|
+
const m = parsed.method;
|
|
225
|
+
if (typeof m !== "string")
|
|
226
|
+
return { rpc_method: "(response)" };
|
|
227
|
+
if (!RPC_METHOD.test(m))
|
|
228
|
+
return { rpc_method: "(other)" };
|
|
229
|
+
if (m !== "tools/call")
|
|
230
|
+
return { rpc_method: m };
|
|
231
|
+
const name = parsed.params?.name;
|
|
232
|
+
return { rpc_method: m, tool: typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)" };
|
|
233
|
+
}
|
|
234
|
+
export function revisionLabel(v) {
|
|
235
|
+
if (v === undefined)
|
|
236
|
+
return "(none)";
|
|
237
|
+
return REVISION.test(v.trim()) ? v.trim() : "(other)";
|
|
238
|
+
}
|
|
239
|
+
export function uaFamily(ua) {
|
|
240
|
+
if (!ua)
|
|
241
|
+
return "(none)";
|
|
242
|
+
const fam = ua.split(/[/\s]/, 1)[0];
|
|
243
|
+
return UA_FAMILY.test(fam) ? fam : "(other)";
|
|
244
|
+
}
|