@codemieai/code 0.0.54 → 0.0.55
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 +6 -0
- package/bin/codemie-mcp-proxy.js +91 -0
- package/dist/agents/core/BaseAgentAdapter.d.ts.map +1 -1
- package/dist/agents/core/BaseAgentAdapter.js +3 -0
- package/dist/agents/core/BaseAgentAdapter.js.map +1 -1
- package/dist/cli/commands/mcp/index.d.ts +3 -0
- package/dist/cli/commands/mcp/index.d.ts.map +1 -0
- package/dist/cli/commands/mcp/index.js +103 -0
- package/dist/cli/commands/mcp/index.js.map +1 -0
- package/dist/cli/commands/mcp-proxy.d.ts +13 -0
- package/dist/cli/commands/mcp-proxy.d.ts.map +1 -0
- package/dist/cli/commands/mcp-proxy.js +53 -0
- package/dist/cli/commands/mcp-proxy.js.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/mcp/auth/callback-server.d.ts +22 -0
- package/dist/mcp/auth/callback-server.d.ts.map +1 -0
- package/dist/mcp/auth/callback-server.js +87 -0
- package/dist/mcp/auth/callback-server.js.map +1 -0
- package/dist/mcp/auth/mcp-oauth-provider.d.ts +49 -0
- package/dist/mcp/auth/mcp-oauth-provider.d.ts.map +1 -0
- package/dist/mcp/auth/mcp-oauth-provider.js +156 -0
- package/dist/mcp/auth/mcp-oauth-provider.js.map +1 -0
- package/dist/mcp/constants.d.ts +5 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +7 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/proxy-logger.d.ts +7 -0
- package/dist/mcp/proxy-logger.d.ts.map +1 -0
- package/dist/mcp/proxy-logger.js +32 -0
- package/dist/mcp/proxy-logger.js.map +1 -0
- package/dist/mcp/stdio-http-bridge.d.ts +63 -0
- package/dist/mcp/stdio-http-bridge.d.ts.map +1 -0
- package/dist/mcp/stdio-http-bridge.js +307 -0
- package/dist/mcp/stdio-http-bridge.js.map +1 -0
- package/dist/providers/plugins/sso/proxy/plugins/index.d.ts +2 -1
- package/dist/providers/plugins/sso/proxy/plugins/index.d.ts.map +1 -1
- package/dist/providers/plugins/sso/proxy/plugins/index.js +3 -1
- package/dist/providers/plugins/sso/proxy/plugins/index.js.map +1 -1
- package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.d.ts +34 -0
- package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.d.ts.map +1 -0
- package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.js +1200 -0
- package/dist/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.js.map +1 -0
- package/dist/providers/plugins/sso/proxy/plugins/types.d.ts +18 -1
- package/dist/providers/plugins/sso/proxy/plugins/types.d.ts.map +1 -1
- package/dist/providers/plugins/sso/proxy/sso.proxy.d.ts.map +1 -1
- package/dist/providers/plugins/sso/proxy/sso.proxy.js +32 -2
- package/dist/providers/plugins/sso/proxy/sso.proxy.js.map +1 -1
- package/dist/utils/exec.d.ts +1 -0
- package/dist/utils/exec.d.ts.map +1 -1
- package/dist/utils/exec.js +13 -5
- package/dist/utils/exec.js.map +1 -1
- package/package.json +5 -2
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Authorization Proxy Plugin
|
|
3
|
+
* Priority: 3 (runs before endpoint blocker, auth, and all other plugins)
|
|
4
|
+
*
|
|
5
|
+
* Proxies the MCP OAuth authorization flow so that:
|
|
6
|
+
* 1. All auth traffic is routed through the CodeMie proxy
|
|
7
|
+
* 2. `client_name` is replaced with MCP_CLIENT_NAME env var (default "CodeMie CLI") in dynamic client registration
|
|
8
|
+
*
|
|
9
|
+
* URL scheme:
|
|
10
|
+
* - /mcp_auth?original=<url> → Initial MCP connection
|
|
11
|
+
* - /mcp_relay/<root_b64>/<relay_b64>/<path> → Relayed requests (per-flow scoped)
|
|
12
|
+
*
|
|
13
|
+
* The root_b64 segment carries the root MCP server origin for per-flow isolation.
|
|
14
|
+
* The relay_b64 segment identifies the actual target origin (may differ from root
|
|
15
|
+
* when the auth server is on a separate host).
|
|
16
|
+
*
|
|
17
|
+
* Response URL rewriting replaces external URLs with proxy relay URLs so that
|
|
18
|
+
* the MCP client (Claude Code CLI) routes all subsequent requests through the proxy.
|
|
19
|
+
*
|
|
20
|
+
* Security:
|
|
21
|
+
* - SSRF protection: private/loopback origins are rejected (hostname + DNS resolution)
|
|
22
|
+
* - Per-flow origin scoping: discovered origins are tagged with their root MCP server
|
|
23
|
+
* origin and relay requests validate the root-relay association
|
|
24
|
+
* - Buffering is restricted to auth metadata responses; post-auth MCP traffic streams through
|
|
25
|
+
*/
|
|
26
|
+
import { URL } from 'url';
|
|
27
|
+
import { lookup } from 'dns/promises';
|
|
28
|
+
import { gunzip, inflate, brotliDecompress } from 'zlib';
|
|
29
|
+
import { promisify } from 'util';
|
|
30
|
+
import { logger } from '../../../../../utils/logger.js';
|
|
31
|
+
import { getMcpClientName } from '../../../../../mcp/constants.js';
|
|
32
|
+
const gunzipAsync = promisify(gunzip);
|
|
33
|
+
const inflateAsync = promisify(inflate);
|
|
34
|
+
const brotliDecompressAsync = promisify(brotliDecompress);
|
|
35
|
+
// ─── URL Utilities ───────────────────────────────────────────────────────────
|
|
36
|
+
/** Base64url encode (RFC 4648 §5): URL-safe, no padding */
|
|
37
|
+
function base64urlEncode(str) {
|
|
38
|
+
return Buffer.from(str, 'utf-8')
|
|
39
|
+
.toString('base64')
|
|
40
|
+
.replace(/\+/g, '-')
|
|
41
|
+
.replace(/\//g, '_')
|
|
42
|
+
.replace(/=+$/, '');
|
|
43
|
+
}
|
|
44
|
+
/** Base64url decode */
|
|
45
|
+
function base64urlDecode(encoded) {
|
|
46
|
+
// Restore standard base64
|
|
47
|
+
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
|
48
|
+
// Re-add padding
|
|
49
|
+
while (base64.length % 4 !== 0) {
|
|
50
|
+
base64 += '=';
|
|
51
|
+
}
|
|
52
|
+
return Buffer.from(base64, 'base64').toString('utf-8');
|
|
53
|
+
}
|
|
54
|
+
/** Extract origin (scheme + host + port) from a URL string */
|
|
55
|
+
function getOrigin(urlStr) {
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(urlStr);
|
|
58
|
+
return u.origin; // e.g. "https://example.com" or "https://example.com:8443"
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Check if a string looks like an absolute HTTP(S) URL */
|
|
65
|
+
function isAbsoluteUrl(str) {
|
|
66
|
+
return str.startsWith('http://') || str.startsWith('https://');
|
|
67
|
+
}
|
|
68
|
+
// JSON field names whose values are token audience identifiers, NOT URLs to access.
|
|
69
|
+
// These must not be rewritten by the generic URL rewriter.
|
|
70
|
+
// Note: 'resource' is handled separately in rewriteJsonValue — it gets special
|
|
71
|
+
// bidirectional rewriting (to proxy URL in responses, back to original in requests).
|
|
72
|
+
const SKIP_REWRITE_FIELDS = new Set([
|
|
73
|
+
'aud', 'audience', 'redirect_uri', 'redirect_uris',
|
|
74
|
+
'issuer', // OIDC issuer — rewriting breaks token issuer validation
|
|
75
|
+
]);
|
|
76
|
+
// Auth server metadata fields whose URLs are browser-facing and must NOT be rewritten.
|
|
77
|
+
// The browser must navigate directly to the real auth server for login flows because:
|
|
78
|
+
// - Cookies/sessions are domain-scoped (won't work through localhost proxy)
|
|
79
|
+
// - SAML/OIDC federation redirects require the real auth server domain
|
|
80
|
+
// - The auth server's HTML/JS pages reference its own origin
|
|
81
|
+
// Programmatic endpoints (token_endpoint, registration_endpoint) ARE rewritten.
|
|
82
|
+
const BROWSER_FACING_FIELDS = new Set([
|
|
83
|
+
'authorization_endpoint',
|
|
84
|
+
'end_session_endpoint',
|
|
85
|
+
]);
|
|
86
|
+
// Max response body size for buffered MCP auth responses (1MB).
|
|
87
|
+
// Auth metadata payloads are typically 1-10KB. This prevents OOM from
|
|
88
|
+
// malicious or misconfigured upstreams.
|
|
89
|
+
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
90
|
+
// Max number of discovered origins to prevent Set explosion from malicious responses.
|
|
91
|
+
// A normal MCP auth flow discovers 2-3 origins (MCP server + auth server).
|
|
92
|
+
const MAX_KNOWN_ORIGINS = 50;
|
|
93
|
+
// Max number of distinct MCP server origins accepted via /mcp_auth.
|
|
94
|
+
// Bounds the SSRF surface: the proxy is localhost-only but this prevents
|
|
95
|
+
// unbounded use as a generic forwarder. A typical setup has 1-3 MCP servers.
|
|
96
|
+
const MAX_MCP_SERVER_ORIGINS = 10;
|
|
97
|
+
// TTL for discovered origins in milliseconds (30 minutes).
|
|
98
|
+
// Bounds the window during which cross-flow origin leakage can occur.
|
|
99
|
+
// Refreshed on each access, so active flows keep their origins alive.
|
|
100
|
+
const ORIGIN_TTL_MS = 30 * 60 * 1000;
|
|
101
|
+
/**
|
|
102
|
+
* Check if a URL origin points to a private, loopback, or link-local network.
|
|
103
|
+
* Prevents SSRF through malicious auth server metadata that advertises internal hosts.
|
|
104
|
+
*/
|
|
105
|
+
function isPrivateOrLoopbackOrigin(origin) {
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(origin);
|
|
108
|
+
const hostname = url.hostname.toLowerCase();
|
|
109
|
+
// Loopback
|
|
110
|
+
if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]')
|
|
111
|
+
return true;
|
|
112
|
+
// IPv4 ranges
|
|
113
|
+
const parts = hostname.split('.');
|
|
114
|
+
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
|
|
115
|
+
const [a, b] = parts.map(Number);
|
|
116
|
+
if (a === 127)
|
|
117
|
+
return true; // 127.0.0.0/8 loopback
|
|
118
|
+
if (a === 10)
|
|
119
|
+
return true; // 10.0.0.0/8 private
|
|
120
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
121
|
+
return true; // 172.16.0.0/12 private
|
|
122
|
+
if (a === 192 && b === 168)
|
|
123
|
+
return true; // 192.168.0.0/16 private
|
|
124
|
+
if (a === 169 && b === 254)
|
|
125
|
+
return true; // 169.254.0.0/16 link-local
|
|
126
|
+
if (a === 0)
|
|
127
|
+
return true; // 0.0.0.0/8
|
|
128
|
+
}
|
|
129
|
+
// IPv6 (may be bracketed in URL hostnames)
|
|
130
|
+
const ipv6 = hostname.replace(/^\[|\]$/g, '');
|
|
131
|
+
if (ipv6.startsWith('fc') || ipv6.startsWith('fd'))
|
|
132
|
+
return true; // fc00::/7 ULA
|
|
133
|
+
if (ipv6.startsWith('fe80'))
|
|
134
|
+
return true; // fe80::/10 link-local
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return true; // Can't parse — reject to be safe
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Check if a resolved IP address is in a private, loopback, or link-local range.
|
|
143
|
+
* Used for DNS resolution SSRF validation (catches DNS rebinding attacks where
|
|
144
|
+
* a public hostname resolves to an internal IP).
|
|
145
|
+
*/
|
|
146
|
+
function isPrivateOrLoopbackIP(ip) {
|
|
147
|
+
// Handle IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
|
148
|
+
const ipv4Mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
149
|
+
if (ipv4Mapped) {
|
|
150
|
+
return isPrivateOrLoopbackIP(ipv4Mapped[1]);
|
|
151
|
+
}
|
|
152
|
+
// IPv4
|
|
153
|
+
const parts = ip.split('.');
|
|
154
|
+
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
|
|
155
|
+
const [a, b] = parts.map(Number);
|
|
156
|
+
if (a === 127)
|
|
157
|
+
return true; // 127.0.0.0/8 loopback
|
|
158
|
+
if (a === 10)
|
|
159
|
+
return true; // 10.0.0.0/8 private
|
|
160
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
161
|
+
return true; // 172.16.0.0/12 private
|
|
162
|
+
if (a === 192 && b === 168)
|
|
163
|
+
return true; // 192.168.0.0/16 private
|
|
164
|
+
if (a === 169 && b === 254)
|
|
165
|
+
return true; // 169.254.0.0/16 link-local
|
|
166
|
+
if (a === 0)
|
|
167
|
+
return true; // 0.0.0.0/8
|
|
168
|
+
}
|
|
169
|
+
// IPv6
|
|
170
|
+
const normalized = ip.toLowerCase();
|
|
171
|
+
if (normalized === '::1' || normalized === '::')
|
|
172
|
+
return true;
|
|
173
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd'))
|
|
174
|
+
return true; // ULA
|
|
175
|
+
if (normalized.startsWith('fe80'))
|
|
176
|
+
return true; // Link-local
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve a hostname via DNS and check if it points to a private/loopback IP.
|
|
181
|
+
* Defense-in-depth against DNS rebinding SSRF attacks.
|
|
182
|
+
*
|
|
183
|
+
* Note: There is an inherent TOCTOU window between this check and the actual
|
|
184
|
+
* HTTP connection (the hostname could re-resolve differently). This is mitigated
|
|
185
|
+
* by OS-level DNS caching and the short interval between check and connect.
|
|
186
|
+
*/
|
|
187
|
+
async function resolvesToPrivateIP(hostname) {
|
|
188
|
+
// Skip DNS resolution for IP literals — already checked by isPrivateOrLoopbackOrigin
|
|
189
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const { address } = await lookup(hostname);
|
|
194
|
+
return isPrivateOrLoopbackIP(address);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// DNS resolution failed — let the HTTP client handle the error naturally
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Normalize a URL to origin + pathname (lowercased) for endpoint comparison.
|
|
203
|
+
* Strips query params so that `https://auth/register?foo=1` matches `https://auth/register`.
|
|
204
|
+
*/
|
|
205
|
+
function normalizeEndpointUrl(url) {
|
|
206
|
+
try {
|
|
207
|
+
const parsed = new URL(url);
|
|
208
|
+
return (parsed.origin + parsed.pathname).toLowerCase();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return url.toLowerCase();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Query parameter names that may contain sensitive auth data and must be masked in logs.
|
|
215
|
+
const SENSITIVE_QUERY_PARAMS = new Set([
|
|
216
|
+
'code', 'state', 'token', 'access_token', 'refresh_token',
|
|
217
|
+
'id_token', 'session_state', 'client_secret',
|
|
218
|
+
]);
|
|
219
|
+
/**
|
|
220
|
+
* Mask sensitive query parameter values in a URL for safe logging.
|
|
221
|
+
* Handles nested URLs: if a parameter value itself contains a URL with
|
|
222
|
+
* sensitive params (e.g., original=https://idp/callback?code=abc&state=xyz),
|
|
223
|
+
* those nested values are also masked.
|
|
224
|
+
*/
|
|
225
|
+
function sanitizeUrlForLog(url) {
|
|
226
|
+
const queryStart = url.indexOf('?');
|
|
227
|
+
if (queryStart === -1)
|
|
228
|
+
return url;
|
|
229
|
+
const basePath = url.slice(0, queryStart);
|
|
230
|
+
const queryString = url.slice(queryStart + 1);
|
|
231
|
+
const sanitizedParams = queryString.split('&').map(param => {
|
|
232
|
+
const eqIdx = param.indexOf('=');
|
|
233
|
+
if (eqIdx === -1)
|
|
234
|
+
return param;
|
|
235
|
+
const key = param.slice(0, eqIdx).toLowerCase();
|
|
236
|
+
if (SENSITIVE_QUERY_PARAMS.has(key)) {
|
|
237
|
+
return `${param.slice(0, eqIdx)}=***`;
|
|
238
|
+
}
|
|
239
|
+
// Recursively sanitize nested URLs in parameter values
|
|
240
|
+
const value = param.slice(eqIdx + 1);
|
|
241
|
+
if (isAbsoluteUrl(value) || isAbsoluteUrl(decodeURIComponentSafe(value))) {
|
|
242
|
+
const sanitizedValue = sanitizeUrlForLog(decodeURIComponentSafe(value));
|
|
243
|
+
return `${param.slice(0, eqIdx)}=${sanitizedValue}`;
|
|
244
|
+
}
|
|
245
|
+
return param;
|
|
246
|
+
});
|
|
247
|
+
return `${basePath}?${sanitizedParams.join('&')}`;
|
|
248
|
+
}
|
|
249
|
+
/** Safe decodeURIComponent that returns the input on failure */
|
|
250
|
+
function decodeURIComponentSafe(str) {
|
|
251
|
+
try {
|
|
252
|
+
return decodeURIComponent(str);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return str;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────────
|
|
259
|
+
export class MCPAuthPlugin {
|
|
260
|
+
id = '@codemie/proxy-mcp-auth';
|
|
261
|
+
name = 'MCP Auth Proxy';
|
|
262
|
+
version = '1.0.0';
|
|
263
|
+
priority = 3; // Before endpoint blocker (5) and auth (10)
|
|
264
|
+
async createInterceptor(context) {
|
|
265
|
+
return new MCPAuthInterceptor(context);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// ─── Interceptor ─────────────────────────────────────────────────────────────
|
|
269
|
+
class MCPAuthInterceptor {
|
|
270
|
+
pluginContext;
|
|
271
|
+
name = 'mcp-auth';
|
|
272
|
+
/** Proxy's own base URL, set on first MCP auth request */
|
|
273
|
+
proxyBaseUrl = null;
|
|
274
|
+
/**
|
|
275
|
+
* Known external origins discovered from auth metadata responses.
|
|
276
|
+
* Map from origin → entry with TTL and per-flow root origin scoping.
|
|
277
|
+
* Origins are ONLY added through validated auth flow responses (WWW-Authenticate,
|
|
278
|
+
* auth server metadata, Location redirects) — never from caller-supplied URLs.
|
|
279
|
+
* Private/loopback origins are rejected to prevent SSRF.
|
|
280
|
+
*
|
|
281
|
+
* Per-flow scoping: each discovered origin is tagged with the root MCP server
|
|
282
|
+
* origin it was discovered from. Relay requests validate that the relay origin
|
|
283
|
+
* was discovered from the root origin carried in the URL. This prevents
|
|
284
|
+
* cross-flow origin leakage (flow A's discovered origins are not usable by flow B).
|
|
285
|
+
*/
|
|
286
|
+
knownOrigins = new Map();
|
|
287
|
+
/**
|
|
288
|
+
* Normalized URLs (origin+pathname) of discovered registration endpoints.
|
|
289
|
+
* Used to match client_name replacement targets beyond the /register path heuristic.
|
|
290
|
+
*/
|
|
291
|
+
discoveredRegistrationEndpoints = new Set();
|
|
292
|
+
/**
|
|
293
|
+
* Normalized URLs (origin+pathname) of all discovered auth endpoints
|
|
294
|
+
* (token, registration, authorization, jwks, etc.).
|
|
295
|
+
* Used for buffering decisions in isAuthMetadataResponse beyond path heuristics.
|
|
296
|
+
*/
|
|
297
|
+
discoveredAuthEndpoints = new Set();
|
|
298
|
+
/**
|
|
299
|
+
* Distinct MCP server origins accessed via /mcp_auth.
|
|
300
|
+
* Bounded by MAX_MCP_SERVER_ORIGINS to prevent unbounded use as a generic forwarder.
|
|
301
|
+
*/
|
|
302
|
+
mcpServerOrigins = new Set();
|
|
303
|
+
/**
|
|
304
|
+
* Mapping from original MCP server URLs to their proxy /mcp_auth URLs.
|
|
305
|
+
* Used for bidirectional 'resource' field rewriting:
|
|
306
|
+
* - Response: resource "https://real-server/path" → "http://localhost:PORT/mcp_auth?original=https://real-server/path"
|
|
307
|
+
* - Request: reverse mapping before forwarding to auth server
|
|
308
|
+
* This is needed because the MCP SDK validates that the resource metadata's
|
|
309
|
+
* 'resource' field matches the URL the client originally connected to.
|
|
310
|
+
*/
|
|
311
|
+
mcpUrlMapping = new Map();
|
|
312
|
+
constructor(pluginContext) {
|
|
313
|
+
this.pluginContext = pluginContext;
|
|
314
|
+
}
|
|
315
|
+
async onProxyStop() {
|
|
316
|
+
this.proxyBaseUrl = null;
|
|
317
|
+
this.knownOrigins.clear();
|
|
318
|
+
this.mcpServerOrigins.clear();
|
|
319
|
+
this.mcpUrlMapping.clear();
|
|
320
|
+
this.discoveredRegistrationEndpoints.clear();
|
|
321
|
+
this.discoveredAuthEndpoints.clear();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Check if an origin is known, not expired, and (optionally) associated with a
|
|
325
|
+
* specific root MCP server origin. Refreshes TTL on access.
|
|
326
|
+
*/
|
|
327
|
+
isKnownOrigin(origin, rootOrigin) {
|
|
328
|
+
const entry = this.knownOrigins.get(origin);
|
|
329
|
+
if (!entry)
|
|
330
|
+
return false;
|
|
331
|
+
if (Date.now() - entry.timestamp > ORIGIN_TTL_MS) {
|
|
332
|
+
this.knownOrigins.delete(origin);
|
|
333
|
+
logger.debug(`[${this.name}] Origin expired and removed: ${origin}`);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
// Per-flow validation: if rootOrigin specified, verify association
|
|
337
|
+
if (rootOrigin && !entry.rootOrigins.has(rootOrigin)) {
|
|
338
|
+
logger.debug(`[${this.name}] Origin ${origin} not associated with root ${rootOrigin}`);
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
entry.timestamp = Date.now(); // Refresh on access
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Add a discovered origin with private-network validation, TTL, and root tagging.
|
|
346
|
+
* If the origin already exists, adds the rootOrigin to its set and refreshes TTL.
|
|
347
|
+
*/
|
|
348
|
+
addKnownOrigin(origin, rootOrigin) {
|
|
349
|
+
const existing = this.knownOrigins.get(origin);
|
|
350
|
+
if (existing) {
|
|
351
|
+
existing.timestamp = Date.now();
|
|
352
|
+
existing.rootOrigins.add(rootOrigin);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
if (isPrivateOrLoopbackOrigin(origin)) {
|
|
356
|
+
logger.debug(`[${this.name}] Rejected private/loopback origin: ${origin}`);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
// Sweep expired entries before checking capacity so stale origins
|
|
360
|
+
// don't prevent legitimate new origins from being added.
|
|
361
|
+
if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS) {
|
|
362
|
+
this.sweepExpiredOrigins();
|
|
363
|
+
}
|
|
364
|
+
if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
|
|
365
|
+
return false;
|
|
366
|
+
this.knownOrigins.set(origin, {
|
|
367
|
+
timestamp: Date.now(),
|
|
368
|
+
rootOrigins: new Set([rootOrigin]),
|
|
369
|
+
});
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
/** Remove all expired origins from the map. */
|
|
373
|
+
sweepExpiredOrigins() {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
for (const [origin, entry] of this.knownOrigins) {
|
|
376
|
+
if (now - entry.timestamp > ORIGIN_TTL_MS) {
|
|
377
|
+
this.knownOrigins.delete(origin);
|
|
378
|
+
logger.debug(`[${this.name}] Swept expired origin: ${origin}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Handle MCP auth requests directly, bypassing normal proxy flow.
|
|
384
|
+
* Returns true if the request was handled (path matched /mcp_auth or /mcp_relay).
|
|
385
|
+
*
|
|
386
|
+
* Why this bypasses the standard pipeline:
|
|
387
|
+
* MCP auth traffic routes to MCP/auth servers (not LLM APIs), so the standard
|
|
388
|
+
* proxy plugins (endpoint blocker, auth injection, request sanitizer) do not apply.
|
|
389
|
+
* This plugin implements its own SSRF protection, origin validation, and logging.
|
|
390
|
+
*/
|
|
391
|
+
async handleRequest(context, _req, res, httpClient) {
|
|
392
|
+
const url = context.url; // e.g. "/mcp_auth?original=..." or "/mcp_relay/root/relay/path"
|
|
393
|
+
// Route 1: Initial MCP connection (exact path boundary: /mcp_auth or /mcp_auth?...)
|
|
394
|
+
if (url === '/mcp_auth' || url.startsWith('/mcp_auth?')) {
|
|
395
|
+
const safeUrl = sanitizeUrlForLog(url);
|
|
396
|
+
logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`);
|
|
397
|
+
const startTime = Date.now();
|
|
398
|
+
await this.handleMCPAuth(context, res, httpClient);
|
|
399
|
+
logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`);
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
// Route 2: Relayed request to external host
|
|
403
|
+
if (url.startsWith('/mcp_relay/')) {
|
|
404
|
+
const safeUrl = sanitizeUrlForLog(url);
|
|
405
|
+
logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`);
|
|
406
|
+
const startTime = Date.now();
|
|
407
|
+
await this.handleMCPRelay(context, res, httpClient);
|
|
408
|
+
logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
// Route 3: RFC 8414 well-known URLs constructed over mcp_relay paths.
|
|
412
|
+
// OAuth SDKs construct well-known URLs by inserting .well-known at the URL root:
|
|
413
|
+
// /.well-known/<type>/mcp_relay/<root>/<relay>/<issuer_path>
|
|
414
|
+
// Rewrite to relay form so handleMCPRelay processes it:
|
|
415
|
+
// /mcp_relay/<root>/<relay>/.well-known/<type>/<issuer_path>
|
|
416
|
+
if (url.startsWith('/.well-known/') && url.includes('/mcp_relay/')) {
|
|
417
|
+
const mcpRelayIdx = url.indexOf('/mcp_relay/');
|
|
418
|
+
const wellKnownPart = url.slice(0, mcpRelayIdx); // e.g. "/.well-known/oauth-authorization-server"
|
|
419
|
+
const relaySegment = url.slice(mcpRelayIdx + '/mcp_relay/'.length); // <root>/<relay>/<issuer_path>[?query]
|
|
420
|
+
// Extract root and relay segments, then reconstruct
|
|
421
|
+
const firstSlash = relaySegment.indexOf('/');
|
|
422
|
+
if (firstSlash !== -1) {
|
|
423
|
+
const rootEnc = relaySegment.slice(0, firstSlash);
|
|
424
|
+
const afterRoot = relaySegment.slice(firstSlash + 1);
|
|
425
|
+
const secondSlash = afterRoot.indexOf('/');
|
|
426
|
+
const secondQuery = afterRoot.indexOf('?');
|
|
427
|
+
const secondSep = secondSlash === -1 ? secondQuery
|
|
428
|
+
: secondQuery === -1 ? secondSlash
|
|
429
|
+
: Math.min(secondSlash, secondQuery);
|
|
430
|
+
const relayEnc = secondSep === -1 ? afterRoot : afterRoot.slice(0, secondSep);
|
|
431
|
+
const issuerRest = secondSep === -1 ? '' : afterRoot.slice(secondSep); // e.g. "/keycloak_prod/..."
|
|
432
|
+
// Reconstruct: /mcp_relay/<root>/<relay>/.well-known/<type>/<issuer_path>
|
|
433
|
+
const rewrittenUrl = `/mcp_relay/${rootEnc}/${relayEnc}${wellKnownPart}${issuerRest}`;
|
|
434
|
+
logger.debug(`[${this.name}] RFC 8414 well-known rewrite: ${url} → ${rewrittenUrl}`);
|
|
435
|
+
context.url = rewrittenUrl;
|
|
436
|
+
const safeUrl = sanitizeUrlForLog(rewrittenUrl);
|
|
437
|
+
logger.info(`[${this.name}] ${context.method} ${safeUrl} [${context.requestId}]`);
|
|
438
|
+
const startTime = Date.now();
|
|
439
|
+
await this.handleMCPRelay(context, res, httpClient);
|
|
440
|
+
logger.info(`[${this.name}] ${context.method} ${safeUrl} → ${res.statusCode} (${Date.now() - startTime}ms) [${context.requestId}]`);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Not an MCP auth request — let normal proxy flow handle it
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
// ─── Route Handlers ──────────────────────────────────────────────────────
|
|
448
|
+
/**
|
|
449
|
+
* Handle /mcp_auth?original=<url>
|
|
450
|
+
* Extracts the real MCP server URL and forwards the request.
|
|
451
|
+
*/
|
|
452
|
+
async handleMCPAuth(context, res, httpClient) {
|
|
453
|
+
// Set proxy base URL on first request
|
|
454
|
+
if (!this.proxyBaseUrl) {
|
|
455
|
+
const proxyPort = this.pluginContext.config.port;
|
|
456
|
+
this.proxyBaseUrl = `http://localhost:${proxyPort}`;
|
|
457
|
+
}
|
|
458
|
+
// Extract original URL from query string
|
|
459
|
+
const originalUrl = this.extractOriginalUrl(context.url);
|
|
460
|
+
logger.debug(`[${this.name}] Extracted originalUrl: ${originalUrl}`);
|
|
461
|
+
if (!originalUrl) {
|
|
462
|
+
this.sendError(res, 400, 'Missing or invalid "original" query parameter');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// SSRF check: reject private/loopback targets in the original URL.
|
|
466
|
+
const origin = getOrigin(originalUrl);
|
|
467
|
+
logger.debug(`[${this.name}] Original origin: ${origin}`);
|
|
468
|
+
if (isPrivateOrLoopbackOrigin(originalUrl)) {
|
|
469
|
+
this.sendError(res, 403, 'SSRF blocked: original URL points to private/loopback network');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// Track and cap distinct MCP server origins to prevent use as a generic forwarder.
|
|
473
|
+
// The /mcp_auth path must forward to arbitrary user-configured URLs (by design),
|
|
474
|
+
// but we bound the number of distinct origins to limit SSRF surface.
|
|
475
|
+
if (origin && !this.mcpServerOrigins.has(origin)) {
|
|
476
|
+
if (this.mcpServerOrigins.size >= MAX_MCP_SERVER_ORIGINS) {
|
|
477
|
+
this.sendError(res, 403, `MCP server origin limit reached (${MAX_MCP_SERVER_ORIGINS}). Cannot forward to new origins.`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.mcpServerOrigins.add(origin);
|
|
481
|
+
logger.debug(`[${this.name}] Registered MCP server origin: ${origin} (${this.mcpServerOrigins.size}/${MAX_MCP_SERVER_ORIGINS})`);
|
|
482
|
+
}
|
|
483
|
+
// The root origin is the MCP server's origin — used for per-flow origin scoping
|
|
484
|
+
const rootOrigin = origin || '';
|
|
485
|
+
// Store mapping for bidirectional 'resource' field rewriting.
|
|
486
|
+
// The MCP SDK validates resource metadata's 'resource' field against the connected URL.
|
|
487
|
+
const proxyUrl = `${this.proxyBaseUrl}/mcp_auth?original=${originalUrl}`;
|
|
488
|
+
this.mcpUrlMapping.set(originalUrl, proxyUrl);
|
|
489
|
+
logger.debug(`[${this.name}] Stored resource mapping: "${originalUrl}" → "${proxyUrl}"`);
|
|
490
|
+
logger.debug(`[${this.name}] Initial MCP auth request → ${originalUrl} [rootOrigin=${rootOrigin}]`);
|
|
491
|
+
await this.forwardAndRewrite(context, res, httpClient, originalUrl, rootOrigin);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Handle /mcp_relay/<root_b64>/<relay_b64>/<path>
|
|
495
|
+
* Decodes both origins, validates the root-relay association, and forwards.
|
|
496
|
+
*
|
|
497
|
+
* URL scheme: /mcp_relay/<encoded_root_origin>/<encoded_relay_origin>/<rest_of_path>
|
|
498
|
+
* The root origin identifies which MCP flow this relay belongs to.
|
|
499
|
+
* The relay origin is the actual target host (may differ from root for auth servers).
|
|
500
|
+
*/
|
|
501
|
+
async handleMCPRelay(context, res, httpClient) {
|
|
502
|
+
// Parse: /mcp_relay/<encoded_root>/<encoded_relay>/<rest>
|
|
503
|
+
const withoutPrefix = context.url.slice('/mcp_relay/'.length);
|
|
504
|
+
// Find first slash — separates encoded_root from encoded_relay
|
|
505
|
+
const firstSlash = withoutPrefix.indexOf('/');
|
|
506
|
+
if (firstSlash === -1) {
|
|
507
|
+
this.sendError(res, 400, 'Invalid /mcp_relay path: missing relay origin segment');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const encodedRoot = withoutPrefix.slice(0, firstSlash);
|
|
511
|
+
const afterRoot = withoutPrefix.slice(firstSlash + 1);
|
|
512
|
+
// Find second separator (/ or ?) — separates encoded_relay from path
|
|
513
|
+
const secondSlash = afterRoot.indexOf('/');
|
|
514
|
+
const queryIdx = afterRoot.indexOf('?');
|
|
515
|
+
const secondSep = secondSlash === -1 ? queryIdx
|
|
516
|
+
: queryIdx === -1 ? secondSlash
|
|
517
|
+
: Math.min(secondSlash, queryIdx);
|
|
518
|
+
let encodedRelay;
|
|
519
|
+
let pathAndQuery;
|
|
520
|
+
if (secondSep === -1) {
|
|
521
|
+
encodedRelay = afterRoot;
|
|
522
|
+
pathAndQuery = '/';
|
|
523
|
+
}
|
|
524
|
+
else if (afterRoot[secondSep] === '?') {
|
|
525
|
+
// Query directly on root: /mcp_relay/<root>/<relay>?x=1 → origin + /?x=1
|
|
526
|
+
encodedRelay = afterRoot.slice(0, secondSep);
|
|
527
|
+
pathAndQuery = '/' + afterRoot.slice(secondSep); // → /?x=1
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
encodedRelay = afterRoot.slice(0, secondSep);
|
|
531
|
+
pathAndQuery = afterRoot.slice(secondSep); // includes leading /
|
|
532
|
+
}
|
|
533
|
+
let decodedRoot;
|
|
534
|
+
let decodedRelay;
|
|
535
|
+
try {
|
|
536
|
+
decodedRoot = base64urlDecode(encodedRoot);
|
|
537
|
+
decodedRelay = base64urlDecode(encodedRelay);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
this.sendError(res, 400, 'Invalid encoded origin in /mcp_relay path');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
logger.debug(`[${this.name}] Relay parsed: root="${decodedRoot}" relay="${decodedRelay}" pathAndQuery="${pathAndQuery}"`);
|
|
544
|
+
if (!isAbsoluteUrl(decodedRoot) || !isAbsoluteUrl(decodedRelay)) {
|
|
545
|
+
this.sendError(res, 400, 'Decoded origin is not a valid URL');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// Auto-register origins from relay URLs if not already known.
|
|
549
|
+
// The MCP SDK may cache auth state (resource metadata URLs) across sessions.
|
|
550
|
+
// When a new session starts, knownOrigins is empty but the SDK reuses cached
|
|
551
|
+
// /mcp_relay/... URLs from a previous session. The URLs were generated by our
|
|
552
|
+
// own proxy, so the encoded origins are trustworthy (still SSRF-checked).
|
|
553
|
+
if (!this.knownOrigins.has(decodedRoot)) {
|
|
554
|
+
if (!isPrivateOrLoopbackOrigin(decodedRoot)) {
|
|
555
|
+
this.addKnownOrigin(decodedRoot, decodedRoot);
|
|
556
|
+
this.mcpServerOrigins.add(decodedRoot);
|
|
557
|
+
logger.debug(`[${this.name}] Auto-registered root origin from cached relay URL: ${decodedRoot}`);
|
|
558
|
+
// Reconstruct the mcpUrlMapping for resource field rewriting.
|
|
559
|
+
// We don't have the full original URL, but the proxy base URL is set.
|
|
560
|
+
if (!this.proxyBaseUrl) {
|
|
561
|
+
const proxyPort = this.pluginContext.config.port;
|
|
562
|
+
this.proxyBaseUrl = `http://localhost:${proxyPort}`;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (decodedRoot !== decodedRelay && !this.knownOrigins.has(decodedRelay)) {
|
|
567
|
+
if (!isPrivateOrLoopbackOrigin(decodedRelay)) {
|
|
568
|
+
this.addKnownOrigin(decodedRelay, decodedRoot);
|
|
569
|
+
logger.debug(`[${this.name}] Auto-registered relay origin from cached relay URL: ${decodedRelay}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Per-flow validation: the relay origin must have been discovered from this root flow
|
|
573
|
+
if (!this.isKnownOrigin(decodedRelay, decodedRoot)) {
|
|
574
|
+
logger.debug(`[${this.name}] Origin check failed: relay="${decodedRelay}" root="${decodedRoot}" knownOrigins=${JSON.stringify([...this.knownOrigins.entries()].map(([k, v]) => ({ origin: k, roots: [...v.rootOrigins] })))}`);
|
|
575
|
+
this.sendError(res, 403, 'Origin not allowed — not discovered through this MCP auth flow');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const targetUrl = decodedRelay + pathAndQuery;
|
|
579
|
+
logger.debug(`[${this.name}] MCP relay request [root=${decodedRoot}] → ${targetUrl}`);
|
|
580
|
+
await this.forwardAndRewrite(context, res, httpClient, targetUrl, decodedRoot);
|
|
581
|
+
}
|
|
582
|
+
// ─── Core: Forward + Rewrite ─────────────────────────────────────────────
|
|
583
|
+
/**
|
|
584
|
+
* Forward a request to the target URL.
|
|
585
|
+
* - Auth metadata responses (401, .well-known, /register, /token) are buffered for URL rewriting.
|
|
586
|
+
* - Everything else (authenticated MCP traffic, SSE, binary) is streamed through.
|
|
587
|
+
*/
|
|
588
|
+
async forwardAndRewrite(context, res, httpClient, targetUrl, rootOrigin) {
|
|
589
|
+
// Modify request body if needed (client_name replacement)
|
|
590
|
+
let requestBody = context.requestBody;
|
|
591
|
+
const headers = { ...context.headers };
|
|
592
|
+
// Strip accept-encoding so upstream returns uncompressed JSON responses.
|
|
593
|
+
// Needed because the buffer path parses JSON for URL rewriting. We can't know
|
|
594
|
+
// which path (buffer vs stream) until after the response arrives, so strip early.
|
|
595
|
+
delete headers['accept-encoding'];
|
|
596
|
+
logger.debug(`[${this.name}] forwardAndRewrite: ${context.method} ${targetUrl} [rootOrigin=${rootOrigin}]`);
|
|
597
|
+
// Only rewrite client_name for POST to /register (OAuth dynamic client registration).
|
|
598
|
+
// Other JSON payloads must not be mutated.
|
|
599
|
+
const isRegEndpoint = this.isRegistrationEndpoint(targetUrl);
|
|
600
|
+
if (requestBody && context.method === 'POST'
|
|
601
|
+
&& headers['content-type']?.includes('application/json')
|
|
602
|
+
&& isRegEndpoint) {
|
|
603
|
+
logger.debug(`[${this.name}] Rewriting client_name in registration request`);
|
|
604
|
+
requestBody = this.rewriteRequestBody(requestBody, headers);
|
|
605
|
+
}
|
|
606
|
+
// Reverse-rewrite 'resource' parameter: proxy /mcp_auth URL → original URL.
|
|
607
|
+
if (requestBody && context.method === 'POST') {
|
|
608
|
+
const bodyBefore = requestBody;
|
|
609
|
+
requestBody = this.reverseRewriteResourceInBody(requestBody, headers);
|
|
610
|
+
if (requestBody !== bodyBefore) {
|
|
611
|
+
logger.debug(`[${this.name}] Reverse-rewrote resource in request body`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// DNS resolution SSRF check
|
|
615
|
+
const parsedTarget = new URL(targetUrl);
|
|
616
|
+
// Also reverse-rewrite 'resource' in URL query params (authorization endpoint GET)
|
|
617
|
+
this.reverseRewriteResourceParam(parsedTarget);
|
|
618
|
+
logger.debug(`[${this.name}] DNS check for hostname: ${parsedTarget.hostname}`);
|
|
619
|
+
if (await resolvesToPrivateIP(parsedTarget.hostname)) {
|
|
620
|
+
this.sendError(res, 403, 'SSRF blocked: target hostname resolves to private/loopback address');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
// Forward to target
|
|
624
|
+
const upstreamResponse = await httpClient.forward(parsedTarget, {
|
|
625
|
+
method: context.method,
|
|
626
|
+
headers,
|
|
627
|
+
body: requestBody || undefined
|
|
628
|
+
});
|
|
629
|
+
const statusCode = upstreamResponse.statusCode || 200;
|
|
630
|
+
const contentType = upstreamResponse.headers['content-type'] || '';
|
|
631
|
+
const isJson = contentType.includes('application/json') || contentType.includes('text/json');
|
|
632
|
+
const isAuthMeta = this.isAuthMetadataResponse(targetUrl, statusCode);
|
|
633
|
+
const needsBodyRewriting = isJson && isAuthMeta;
|
|
634
|
+
logger.debug(`[${this.name}] Upstream response: status=${statusCode} contentType="${contentType}" isJson=${isJson} isAuthMeta=${isAuthMeta} needsBodyRewriting=${needsBodyRewriting}`);
|
|
635
|
+
logger.debug(`[${this.name}] Response headers: ${JSON.stringify(upstreamResponse.headers)}`);
|
|
636
|
+
if (needsBodyRewriting) {
|
|
637
|
+
await this.bufferAndRewrite(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
await this.streamThrough(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Buffer response, rewrite URLs in body and headers, send to client.
|
|
645
|
+
* Used for auth metadata responses (401, JSON) that need URL rewriting.
|
|
646
|
+
*/
|
|
647
|
+
async bufferAndRewrite(context, res, upstreamResponse, targetUrl, statusCode, rootOrigin) {
|
|
648
|
+
// Buffer response body (with size limit to prevent OOM)
|
|
649
|
+
const chunks = [];
|
|
650
|
+
let totalSize = 0;
|
|
651
|
+
for await (const chunk of upstreamResponse) {
|
|
652
|
+
totalSize += chunk.length;
|
|
653
|
+
if (totalSize > MAX_RESPONSE_SIZE) {
|
|
654
|
+
upstreamResponse.destroy();
|
|
655
|
+
this.sendError(res, 502, 'Upstream response too large for MCP auth relay');
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
chunks.push(Buffer.from(chunk));
|
|
659
|
+
}
|
|
660
|
+
let responseBody = Buffer.concat(chunks);
|
|
661
|
+
// Decompress if upstream returned compressed content despite stripped accept-encoding.
|
|
662
|
+
// Uses async decompression to avoid blocking the event loop.
|
|
663
|
+
const contentEncoding = (upstreamResponse.headers['content-encoding'] || '').toLowerCase();
|
|
664
|
+
if (contentEncoding) {
|
|
665
|
+
try {
|
|
666
|
+
let decompressed;
|
|
667
|
+
if (contentEncoding === 'gzip' || contentEncoding === 'x-gzip') {
|
|
668
|
+
decompressed = await gunzipAsync(responseBody);
|
|
669
|
+
}
|
|
670
|
+
else if (contentEncoding === 'deflate') {
|
|
671
|
+
decompressed = await inflateAsync(responseBody);
|
|
672
|
+
}
|
|
673
|
+
else if (contentEncoding === 'br') {
|
|
674
|
+
decompressed = await brotliDecompressAsync(responseBody);
|
|
675
|
+
}
|
|
676
|
+
if (decompressed) {
|
|
677
|
+
// Check decompressed size to prevent decompression bomb attacks
|
|
678
|
+
if (decompressed.length > MAX_RESPONSE_SIZE) {
|
|
679
|
+
this.sendError(res, 502, 'Decompressed upstream response too large for MCP auth relay');
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
responseBody = decompressed;
|
|
683
|
+
// Remove content-encoding since we've decompressed
|
|
684
|
+
delete upstreamResponse.headers['content-encoding'];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
logger.debug(`[${this.name}] Failed to decompress ${contentEncoding} response, passing through: ${err}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// Rewrite URLs in response body (JSON only)
|
|
692
|
+
const contentType = upstreamResponse.headers['content-type'] || '';
|
|
693
|
+
if (contentType.includes('application/json') || contentType.includes('text/json')) {
|
|
694
|
+
const bodyBefore = responseBody.toString('utf-8');
|
|
695
|
+
logger.debug(`[${this.name}] Response body BEFORE rewrite (${bodyBefore.length} chars): ${bodyBefore.slice(0, 2000)}`);
|
|
696
|
+
responseBody = this.rewriteResponseBody(responseBody, rootOrigin);
|
|
697
|
+
const bodyAfter = responseBody.toString('utf-8');
|
|
698
|
+
logger.debug(`[${this.name}] Response body AFTER rewrite (${bodyAfter.length} chars): ${bodyAfter.slice(0, 2000)}`);
|
|
699
|
+
}
|
|
700
|
+
// Rewrite URLs in response headers
|
|
701
|
+
const responseHeaders = this.rewriteResponseHeaders(upstreamResponse.headers, targetUrl, rootOrigin);
|
|
702
|
+
logger.debug(`[${this.name}] Rewritten response headers: ${JSON.stringify(responseHeaders)}`);
|
|
703
|
+
// Send to client
|
|
704
|
+
res.statusCode = statusCode;
|
|
705
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
706
|
+
if (value !== undefined && !['transfer-encoding', 'connection'].includes(key.toLowerCase())) {
|
|
707
|
+
res.setHeader(key, value);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// Update content-length to match rewritten body
|
|
711
|
+
res.setHeader('content-length', String(responseBody.length));
|
|
712
|
+
res.end(responseBody);
|
|
713
|
+
logger.debug(`[${this.name}] Buffered response sent: ${statusCode}, ${responseBody.length} bytes`);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Stream response through without buffering.
|
|
717
|
+
* Used for authenticated MCP traffic (SSE, binary, large responses).
|
|
718
|
+
* Only headers are rewritten (Location redirects); body is passed through as-is.
|
|
719
|
+
*/
|
|
720
|
+
async streamThrough(_context, res, upstreamResponse, targetUrl, statusCode, rootOrigin) {
|
|
721
|
+
// Rewrite URLs in response headers only (no body rewriting)
|
|
722
|
+
logger.debug(`[${this.name}] streamThrough: status=${statusCode} targetUrl=${targetUrl}`);
|
|
723
|
+
const responseHeaders = this.rewriteResponseHeaders(upstreamResponse.headers, targetUrl, rootOrigin);
|
|
724
|
+
// Set status and headers
|
|
725
|
+
res.statusCode = statusCode;
|
|
726
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
727
|
+
if (value !== undefined && !['transfer-encoding', 'connection'].includes(key.toLowerCase())) {
|
|
728
|
+
res.setHeader(key, value);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// Stream body directly to client, honoring backpressure and aborting on disconnect
|
|
732
|
+
let bytesSent = 0;
|
|
733
|
+
let downstreamClosed = false;
|
|
734
|
+
// Resolve any pending drain wait when the client disconnects
|
|
735
|
+
let onClose = null;
|
|
736
|
+
res.on('close', () => {
|
|
737
|
+
if (!res.writableFinished) {
|
|
738
|
+
downstreamClosed = true;
|
|
739
|
+
upstreamResponse.destroy();
|
|
740
|
+
// Unblock any pending drain await so the handler doesn't hang
|
|
741
|
+
onClose?.();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
for await (const chunk of upstreamResponse) {
|
|
745
|
+
if (downstreamClosed)
|
|
746
|
+
break;
|
|
747
|
+
const canContinue = res.write(chunk);
|
|
748
|
+
bytesSent += chunk.length;
|
|
749
|
+
// Honor backpressure: wait for drain OR close (whichever fires first)
|
|
750
|
+
if (!canContinue && !downstreamClosed) {
|
|
751
|
+
await new Promise(resolve => {
|
|
752
|
+
onClose = resolve;
|
|
753
|
+
res.once('drain', () => { onClose = null; resolve(); });
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (!downstreamClosed) {
|
|
758
|
+
res.end();
|
|
759
|
+
}
|
|
760
|
+
logger.debug(`[${this.name}] Streamed response: ${statusCode}, ${bytesSent} bytes`);
|
|
761
|
+
}
|
|
762
|
+
// ─── Request Body Modification ───────────────────────────────────────────
|
|
763
|
+
/**
|
|
764
|
+
* Replace `client_name` in JSON request bodies.
|
|
765
|
+
* Uses MCP_CLIENT_NAME env var, defaults to "CodeMie CLI".
|
|
766
|
+
* This targets the OAuth dynamic client registration (POST /register).
|
|
767
|
+
*/
|
|
768
|
+
rewriteRequestBody(body, headers) {
|
|
769
|
+
const clientName = getMcpClientName();
|
|
770
|
+
try {
|
|
771
|
+
const parsed = JSON.parse(body.toString('utf-8'));
|
|
772
|
+
if (typeof parsed === 'object' && parsed !== null && 'client_name' in parsed) {
|
|
773
|
+
parsed.client_name = clientName;
|
|
774
|
+
const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8');
|
|
775
|
+
headers['content-length'] = String(newBody.length);
|
|
776
|
+
logger.debug(`[${this.name}] Replaced client_name with "${clientName}"`);
|
|
777
|
+
return newBody;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
// Not valid JSON — pass through unchanged
|
|
782
|
+
}
|
|
783
|
+
return body;
|
|
784
|
+
}
|
|
785
|
+
// ─── Response Body URL Rewriting ─────────────────────────────────────────
|
|
786
|
+
/**
|
|
787
|
+
* Rewrite external URLs in a JSON response body to proxy relay URLs.
|
|
788
|
+
* Discovers new origins from response content (e.g., authorization_servers).
|
|
789
|
+
*/
|
|
790
|
+
rewriteResponseBody(body, rootOrigin) {
|
|
791
|
+
if (!this.proxyBaseUrl)
|
|
792
|
+
return body;
|
|
793
|
+
try {
|
|
794
|
+
const bodyStr = body.toString('utf-8');
|
|
795
|
+
const parsed = JSON.parse(bodyStr);
|
|
796
|
+
// First pass: discover new origins from known fields
|
|
797
|
+
this.discoverOrigins(parsed, rootOrigin);
|
|
798
|
+
// Second pass: rewrite URLs
|
|
799
|
+
const rewritten = this.rewriteJsonValue(parsed, null, rootOrigin);
|
|
800
|
+
return Buffer.from(JSON.stringify(rewritten), 'utf-8');
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
// Not valid JSON — return unchanged
|
|
804
|
+
return body;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Discover new origins from well-known JSON fields.
|
|
809
|
+
* Specifically targets `authorization_servers` in protected resource metadata.
|
|
810
|
+
*/
|
|
811
|
+
discoverOrigins(obj, rootOrigin) {
|
|
812
|
+
if (typeof obj !== 'object' || obj === null)
|
|
813
|
+
return;
|
|
814
|
+
if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
|
|
815
|
+
return;
|
|
816
|
+
if (Array.isArray(obj)) {
|
|
817
|
+
for (const item of obj) {
|
|
818
|
+
this.discoverOrigins(item, rootOrigin);
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const record = obj;
|
|
823
|
+
// authorization_servers: ["https://auth.example.com/realms/r1"]
|
|
824
|
+
if (Array.isArray(record.authorization_servers)) {
|
|
825
|
+
for (const server of record.authorization_servers) {
|
|
826
|
+
if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
|
|
827
|
+
break;
|
|
828
|
+
if (typeof server === 'string' && isAbsoluteUrl(server)) {
|
|
829
|
+
const origin = getOrigin(server);
|
|
830
|
+
if (origin && this.addKnownOrigin(origin, rootOrigin)) {
|
|
831
|
+
logger.debug(`[${this.name}] Discovered auth server origin: ${origin} [root=${rootOrigin}]`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Auth endpoint fields that may be on a different origin than the auth server.
|
|
837
|
+
// e.g., token_endpoint on a CDN or registration_endpoint on a separate service.
|
|
838
|
+
const endpointFields = [
|
|
839
|
+
'token_endpoint', 'registration_endpoint', 'authorization_endpoint',
|
|
840
|
+
'jwks_uri', 'introspection_endpoint', 'revocation_endpoint',
|
|
841
|
+
'userinfo_endpoint', 'end_session_endpoint',
|
|
842
|
+
'device_authorization_endpoint', 'pushed_authorization_request_endpoint',
|
|
843
|
+
'backchannel_authentication_endpoint', 'registration_client_uri',
|
|
844
|
+
];
|
|
845
|
+
for (const field of endpointFields) {
|
|
846
|
+
if (this.knownOrigins.size >= MAX_KNOWN_ORIGINS)
|
|
847
|
+
break;
|
|
848
|
+
const value = record[field];
|
|
849
|
+
if (typeof value === 'string' && isAbsoluteUrl(value)) {
|
|
850
|
+
// Track origin for relay allowlisting
|
|
851
|
+
const origin = getOrigin(value);
|
|
852
|
+
if (origin && this.addKnownOrigin(origin, rootOrigin)) {
|
|
853
|
+
logger.debug(`[${this.name}] Discovered origin from ${field}: ${origin} [root=${rootOrigin}]`);
|
|
854
|
+
}
|
|
855
|
+
// Track normalized endpoint URL for buffering and registration detection
|
|
856
|
+
const normalized = normalizeEndpointUrl(value);
|
|
857
|
+
this.discoveredAuthEndpoints.add(normalized);
|
|
858
|
+
if (field === 'registration_endpoint' || field === 'registration_client_uri') {
|
|
859
|
+
this.discoveredRegistrationEndpoints.add(normalized);
|
|
860
|
+
logger.debug(`[${this.name}] Discovered registration endpoint: ${normalized}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// Recurse into nested objects
|
|
865
|
+
for (const value of Object.values(record)) {
|
|
866
|
+
if (typeof value === 'object' && value !== null) {
|
|
867
|
+
this.discoverOrigins(value, rootOrigin);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Recursively rewrite URL strings in a JSON value.
|
|
873
|
+
* Skips fields in SKIP_REWRITE_FIELDS (token audience identifiers).
|
|
874
|
+
*/
|
|
875
|
+
rewriteJsonValue(value, parentKey, rootOrigin) {
|
|
876
|
+
if (typeof value === 'string') {
|
|
877
|
+
// Special handling: 'resource' is a token audience identifier that the MCP SDK
|
|
878
|
+
// also validates against the connected URL. Map known MCP server URLs to their
|
|
879
|
+
// proxy /mcp_auth URL; leave others unchanged (never rewrite as /mcp_relay).
|
|
880
|
+
if (parentKey === 'resource') {
|
|
881
|
+
if (isAbsoluteUrl(value)) {
|
|
882
|
+
let mapped = this.mcpUrlMapping.get(value);
|
|
883
|
+
// When the SDK uses cached relay URLs, mcpUrlMapping is empty because
|
|
884
|
+
// handleMCPAuth never ran. Reconstruct the mapping on-the-fly: if the
|
|
885
|
+
// resource value's origin is a known MCP server origin (auto-registered
|
|
886
|
+
// from the cached relay URL), build the proxy URL from it.
|
|
887
|
+
if (!mapped && this.proxyBaseUrl) {
|
|
888
|
+
const valOrigin = getOrigin(value);
|
|
889
|
+
if (valOrigin && this.mcpServerOrigins.has(valOrigin)) {
|
|
890
|
+
mapped = `${this.proxyBaseUrl}/mcp_auth?original=${value}`;
|
|
891
|
+
this.mcpUrlMapping.set(value, mapped);
|
|
892
|
+
logger.debug(`[${this.name}] resource field: reconstructed mapping for cached session: "${value}" → "${mapped}"`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
logger.debug(`[${this.name}] resource field: value="${value}" mapped=${mapped ? `"${mapped}"` : 'null (no mapping, keeping as-is)'} mappingKeys=[${[...this.mcpUrlMapping.keys()].join(', ')}]`);
|
|
896
|
+
return mapped || value;
|
|
897
|
+
}
|
|
898
|
+
return value;
|
|
899
|
+
}
|
|
900
|
+
// Skip rewriting for token identifiers and browser-facing endpoints
|
|
901
|
+
if (parentKey && (SKIP_REWRITE_FIELDS.has(parentKey) || BROWSER_FACING_FIELDS.has(parentKey))) {
|
|
902
|
+
return value;
|
|
903
|
+
}
|
|
904
|
+
if (isAbsoluteUrl(value)) {
|
|
905
|
+
return this.rewriteUrl(value, rootOrigin);
|
|
906
|
+
}
|
|
907
|
+
return value;
|
|
908
|
+
}
|
|
909
|
+
if (Array.isArray(value)) {
|
|
910
|
+
return value.map(item => this.rewriteJsonValue(item, parentKey, rootOrigin));
|
|
911
|
+
}
|
|
912
|
+
if (typeof value === 'object' && value !== null) {
|
|
913
|
+
const result = {};
|
|
914
|
+
for (const [key, val] of Object.entries(value)) {
|
|
915
|
+
result[key] = this.rewriteJsonValue(val, key, rootOrigin);
|
|
916
|
+
}
|
|
917
|
+
return result;
|
|
918
|
+
}
|
|
919
|
+
return value;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Rewrite an external URL to a proxy relay URL.
|
|
923
|
+
* Uses the two-segment scheme: /mcp_relay/<root_b64>/<relay_b64>/<path>
|
|
924
|
+
* The root segment enables per-flow origin validation in the relay handler.
|
|
925
|
+
* Unknown origins are passed through unchanged.
|
|
926
|
+
*/
|
|
927
|
+
rewriteUrl(urlStr, rootOrigin) {
|
|
928
|
+
if (!this.proxyBaseUrl)
|
|
929
|
+
return urlStr;
|
|
930
|
+
const origin = getOrigin(urlStr);
|
|
931
|
+
if (!origin)
|
|
932
|
+
return urlStr;
|
|
933
|
+
// Actively block private/loopback URLs — prevent the client from ever receiving
|
|
934
|
+
// internal-network URLs that a malicious auth server might inject.
|
|
935
|
+
if (isPrivateOrLoopbackOrigin(origin)) {
|
|
936
|
+
logger.debug(`[${this.name}] Blocked private/loopback URL in response: ${origin}`);
|
|
937
|
+
return 'urn:codemie:blocked:private-network';
|
|
938
|
+
}
|
|
939
|
+
if (!this.isKnownOrigin(origin, rootOrigin)) {
|
|
940
|
+
logger.debug(`[${this.name}] rewriteUrl: origin "${origin}" not known for root "${rootOrigin}" — passing through`);
|
|
941
|
+
return urlStr; // Unknown external origin — don't rewrite
|
|
942
|
+
}
|
|
943
|
+
// Extract path + query + fragment after the origin
|
|
944
|
+
const pathAndRest = urlStr.slice(origin.length); // e.g. "/path?query=1"
|
|
945
|
+
const encodedRoot = base64urlEncode(rootOrigin);
|
|
946
|
+
const encodedRelay = base64urlEncode(origin);
|
|
947
|
+
const rewritten = `${this.proxyBaseUrl}/mcp_relay/${encodedRoot}/${encodedRelay}${pathAndRest}`;
|
|
948
|
+
logger.debug(`[${this.name}] rewriteUrl: "${urlStr}" → "${rewritten}"`);
|
|
949
|
+
return rewritten;
|
|
950
|
+
}
|
|
951
|
+
// ─── Response Header Rewriting ───────────────────────────────────────────
|
|
952
|
+
/**
|
|
953
|
+
* Rewrite URLs found in response headers.
|
|
954
|
+
* Targets: WWW-Authenticate (resource_metadata), Location (absolute and relative)
|
|
955
|
+
*/
|
|
956
|
+
rewriteResponseHeaders(headers, upstreamUrl, rootOrigin) {
|
|
957
|
+
const result = { ...headers };
|
|
958
|
+
// Rewrite WWW-Authenticate header (resource_metadata="<url>")
|
|
959
|
+
// Handle both single string and string[] (Node.js may expose either form)
|
|
960
|
+
const wwwAuth = result['www-authenticate'];
|
|
961
|
+
if (typeof wwwAuth === 'string') {
|
|
962
|
+
result['www-authenticate'] = this.rewriteWWWAuthenticate(wwwAuth, rootOrigin);
|
|
963
|
+
}
|
|
964
|
+
else if (Array.isArray(wwwAuth)) {
|
|
965
|
+
result['www-authenticate'] = wwwAuth.map(v => this.rewriteWWWAuthenticate(v, rootOrigin));
|
|
966
|
+
}
|
|
967
|
+
// Rewrite Location header (redirects — both absolute and relative)
|
|
968
|
+
const location = result['location'];
|
|
969
|
+
if (typeof location === 'string') {
|
|
970
|
+
let absoluteLocation = location;
|
|
971
|
+
// Resolve relative redirects against the upstream URL's origin
|
|
972
|
+
if (!isAbsoluteUrl(location)) {
|
|
973
|
+
const upstreamOrigin = getOrigin(upstreamUrl);
|
|
974
|
+
if (upstreamOrigin) {
|
|
975
|
+
try {
|
|
976
|
+
absoluteLocation = new URL(location, upstreamUrl).href;
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// Can't resolve — leave as-is
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (isAbsoluteUrl(absoluteLocation)) {
|
|
984
|
+
const locOrigin = getOrigin(absoluteLocation);
|
|
985
|
+
if (locOrigin) {
|
|
986
|
+
this.addKnownOrigin(locOrigin, rootOrigin);
|
|
987
|
+
}
|
|
988
|
+
result['location'] = this.rewriteUrl(absoluteLocation, rootOrigin);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Rewrite URLs inside a WWW-Authenticate header value.
|
|
995
|
+
* Targets: resource_metadata="<url>"
|
|
996
|
+
*/
|
|
997
|
+
rewriteWWWAuthenticate(header, rootOrigin) {
|
|
998
|
+
logger.debug(`[${this.name}] WWW-Authenticate header BEFORE rewrite: ${header}`);
|
|
999
|
+
// Match resource_metadata="<url>"
|
|
1000
|
+
const rewritten = header.replace(/resource_metadata="([^"]+)"/g, (_match, url) => {
|
|
1001
|
+
if (isAbsoluteUrl(url)) {
|
|
1002
|
+
// Discover and register the origin (with SSRF validation and TTL)
|
|
1003
|
+
const origin = getOrigin(url);
|
|
1004
|
+
if (origin) {
|
|
1005
|
+
this.addKnownOrigin(origin, rootOrigin);
|
|
1006
|
+
}
|
|
1007
|
+
const rewritten = this.rewriteUrl(url, rootOrigin);
|
|
1008
|
+
return `resource_metadata="${rewritten}"`;
|
|
1009
|
+
}
|
|
1010
|
+
return _match;
|
|
1011
|
+
});
|
|
1012
|
+
logger.debug(`[${this.name}] WWW-Authenticate header AFTER rewrite: ${rewritten}`);
|
|
1013
|
+
return rewritten;
|
|
1014
|
+
}
|
|
1015
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
1016
|
+
/**
|
|
1017
|
+
* Extract original URL from a proxy /mcp_auth URL.
|
|
1018
|
+
* Returns null if the URL is not a proxy URL.
|
|
1019
|
+
*/
|
|
1020
|
+
extractOriginalFromProxyUrl(url) {
|
|
1021
|
+
if (!this.proxyBaseUrl)
|
|
1022
|
+
return null;
|
|
1023
|
+
const mcpAuthPrefix = this.proxyBaseUrl + '/mcp_auth';
|
|
1024
|
+
if (!url.startsWith(mcpAuthPrefix))
|
|
1025
|
+
return null;
|
|
1026
|
+
const requestPath = url.slice(this.proxyBaseUrl.length);
|
|
1027
|
+
return this.extractOriginalUrl(requestPath);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Reverse-rewrite 'resource' query parameter in a URL.
|
|
1031
|
+
* Converts proxy /mcp_auth URL back to the original MCP server URL.
|
|
1032
|
+
*/
|
|
1033
|
+
reverseRewriteResourceParam(url) {
|
|
1034
|
+
const resource = url.searchParams.get('resource');
|
|
1035
|
+
if (resource) {
|
|
1036
|
+
const original = this.extractOriginalFromProxyUrl(resource);
|
|
1037
|
+
if (original) {
|
|
1038
|
+
url.searchParams.set('resource', original);
|
|
1039
|
+
logger.debug(`[${this.name}] Reverse-rewrote resource query param to original URL`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Reverse-rewrite 'resource' field in request body (JSON or form-encoded).
|
|
1045
|
+
* Converts proxy /mcp_auth URL back to the original MCP server URL.
|
|
1046
|
+
*/
|
|
1047
|
+
reverseRewriteResourceInBody(body, headers) {
|
|
1048
|
+
const contentType = headers['content-type'] || '';
|
|
1049
|
+
// JSON body (some OAuth implementations accept JSON)
|
|
1050
|
+
if (contentType.includes('application/json')) {
|
|
1051
|
+
try {
|
|
1052
|
+
const parsed = JSON.parse(body.toString('utf-8'));
|
|
1053
|
+
if (typeof parsed === 'object' && parsed !== null && typeof parsed.resource === 'string') {
|
|
1054
|
+
const original = this.extractOriginalFromProxyUrl(parsed.resource);
|
|
1055
|
+
if (original) {
|
|
1056
|
+
parsed.resource = original;
|
|
1057
|
+
const newBody = Buffer.from(JSON.stringify(parsed), 'utf-8');
|
|
1058
|
+
headers['content-length'] = String(newBody.length);
|
|
1059
|
+
logger.debug(`[${this.name}] Reverse-rewrote resource in JSON body to original URL`);
|
|
1060
|
+
return newBody;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
catch { /* not valid JSON */ }
|
|
1065
|
+
}
|
|
1066
|
+
// Form-encoded body (standard OAuth token requests)
|
|
1067
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
1068
|
+
try {
|
|
1069
|
+
const bodyStr = body.toString('utf-8');
|
|
1070
|
+
const params = new URLSearchParams(bodyStr);
|
|
1071
|
+
const resource = params.get('resource');
|
|
1072
|
+
if (resource) {
|
|
1073
|
+
const original = this.extractOriginalFromProxyUrl(resource);
|
|
1074
|
+
if (original) {
|
|
1075
|
+
params.set('resource', original);
|
|
1076
|
+
const newBody = Buffer.from(params.toString(), 'utf-8');
|
|
1077
|
+
headers['content-length'] = String(newBody.length);
|
|
1078
|
+
logger.debug(`[${this.name}] Reverse-rewrote resource in form body to original URL`);
|
|
1079
|
+
return newBody;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
catch { /* not valid form data */ }
|
|
1084
|
+
}
|
|
1085
|
+
return body;
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Check if the target URL is an OAuth dynamic client registration endpoint.
|
|
1089
|
+
* First checks against endpoints discovered from auth server metadata
|
|
1090
|
+
* (registration_endpoint, registration_client_uri), then falls back to
|
|
1091
|
+
* the /register path suffix heuristic for pre-metadata requests.
|
|
1092
|
+
*/
|
|
1093
|
+
isRegistrationEndpoint(targetUrl) {
|
|
1094
|
+
// Check against dynamically discovered registration endpoints
|
|
1095
|
+
const normalized = normalizeEndpointUrl(targetUrl);
|
|
1096
|
+
if (this.discoveredRegistrationEndpoints.has(normalized)) {
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
// Fallback: path suffix heuristic for before metadata is discovered
|
|
1100
|
+
try {
|
|
1101
|
+
return new URL(targetUrl).pathname.toLowerCase().endsWith('/register');
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Check if a response is auth metadata that needs body URL rewriting.
|
|
1109
|
+
* First checks against endpoints discovered from auth server metadata, then
|
|
1110
|
+
* falls back to path heuristics (401, .well-known/, /register, /token, /authorize).
|
|
1111
|
+
* Discovered endpoints cover non-standard paths that the heuristics would miss.
|
|
1112
|
+
*/
|
|
1113
|
+
isAuthMetadataResponse(targetUrl, statusCode) {
|
|
1114
|
+
if (statusCode === 401)
|
|
1115
|
+
return true;
|
|
1116
|
+
// Check against dynamically discovered auth endpoints
|
|
1117
|
+
const normalized = normalizeEndpointUrl(targetUrl);
|
|
1118
|
+
if (this.discoveredAuthEndpoints.has(normalized)) {
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
// Fallback: path heuristics for well-known patterns (before metadata is discovered)
|
|
1122
|
+
try {
|
|
1123
|
+
const path = new URL(targetUrl).pathname.toLowerCase();
|
|
1124
|
+
return path.includes('/.well-known/')
|
|
1125
|
+
|| path.endsWith('/register')
|
|
1126
|
+
|| path.endsWith('/token')
|
|
1127
|
+
|| path.endsWith('/authorize');
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Extract the original URL from /mcp_auth?original=<url>
|
|
1135
|
+
* Handles both URL-encoded and raw (unencoded) values.
|
|
1136
|
+
*
|
|
1137
|
+
* Order: raw extraction FIRST (preserves unencoded nested query parameters like
|
|
1138
|
+
* ?original=https://host/p?aud=x&target=https://o/mcp), then URLSearchParams as
|
|
1139
|
+
* fallback for properly percent-encoded values. URLSearchParams must NOT run first
|
|
1140
|
+
* because it silently truncates unencoded nested URLs at the first `&`.
|
|
1141
|
+
*/
|
|
1142
|
+
extractOriginalUrl(requestUrl) {
|
|
1143
|
+
let candidate = null;
|
|
1144
|
+
// 1. Raw extraction — takes everything after "original=" to preserve unencoded
|
|
1145
|
+
// nested query parameters. Boundary check ensures we match a real top-level
|
|
1146
|
+
// param (at start or preceded by &), not a substring inside another value.
|
|
1147
|
+
// Contract: when using raw (unencoded) URLs, `original=` must be the last param.
|
|
1148
|
+
const queryStart = requestUrl.indexOf('?');
|
|
1149
|
+
if (queryStart !== -1) {
|
|
1150
|
+
const queryString = requestUrl.slice(queryStart + 1);
|
|
1151
|
+
const prefix = 'original=';
|
|
1152
|
+
const idx = queryString.indexOf(prefix);
|
|
1153
|
+
if (idx !== -1 && (idx === 0 || queryString[idx - 1] === '&')) {
|
|
1154
|
+
const rawValue = queryString.slice(idx + prefix.length);
|
|
1155
|
+
// Only use raw extraction for unencoded URLs (starts with http:// or https://).
|
|
1156
|
+
// Encoded values (https%3A...) fall through to URLSearchParams which correctly
|
|
1157
|
+
// separates top-level query params (e.g., ?original=https%3A...&trace=1).
|
|
1158
|
+
if (isAbsoluteUrl(rawValue)) {
|
|
1159
|
+
candidate = rawValue;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
// 2. URLSearchParams fallback — handles properly percent-encoded values where
|
|
1164
|
+
// raw extraction didn't find an absolute URL (e.g., double-encoded values).
|
|
1165
|
+
if (!candidate) {
|
|
1166
|
+
try {
|
|
1167
|
+
const parsed = new URL(requestUrl, 'http://localhost');
|
|
1168
|
+
const original = parsed.searchParams.get('original');
|
|
1169
|
+
if (original && isAbsoluteUrl(original)) {
|
|
1170
|
+
candidate = original;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
// Not a valid URL
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// 3. Validate the candidate is fully parseable as a URL.
|
|
1178
|
+
// Catches inputs like "http://%zz" that pass isAbsoluteUrl but fail new URL().
|
|
1179
|
+
if (candidate) {
|
|
1180
|
+
try {
|
|
1181
|
+
new URL(candidate);
|
|
1182
|
+
return candidate;
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return null; // Triggers clean 400 "Missing or invalid" response
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
/** Send a JSON error response */
|
|
1191
|
+
sendError(res, statusCode, message) {
|
|
1192
|
+
res.statusCode = statusCode;
|
|
1193
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1194
|
+
res.end(JSON.stringify({
|
|
1195
|
+
error: { code: 'MCP_AUTH_ERROR', message }
|
|
1196
|
+
}));
|
|
1197
|
+
logger.debug(`[${this.name}] Error ${statusCode}: ${message}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
//# sourceMappingURL=mcp-auth.plugin.js.map
|