From 65f1dd7d7bbd79f3efe5059ef7a9199fec5fd26d Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 06:09:39 -0800 Subject: [PATCH 1/9] fix: sanitize proxy path to prevent SSRF (CodeQL js/request-forgery) --- packages/proxyabl/src/tool-gateway.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 3b8241a..962c9dc 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -992,6 +992,25 @@ const PROXY_PREFIX = process.env.PROXY_PREFIX || "/proxy"; // e.g. "/proxy const PROXY_INJECT_HEADER = process.env.PROXY_INJECT_HEADER || "";// e.g. "X-User-Id" const PROXY_INJECT_QUERY = process.env.PROXY_INJECT_QUERY || "";// e.g. "userId" +function sanitizeProxyPath(rawTail: string): string { + let p = rawTail || "/"; + + // Normalize: ensure we always treat it as a path, not a URL + // Strip repeated leading slashes but keep one + p = "/" + p.replace(/^\/+/, ""); + + // Block attempts to smuggle in absolute URLs: + // - "//evil.com" (network-path reference) + // - "http://..." or "https://..." or any "scheme://" + if (p.startsWith("//") || p.includes("://")) { + const err: any = new Error("INVALID_UPSTREAM_PATH"); + err.status = 400; + throw err; + } + + return p; +} + if (PROXY_TARGET) { console.log("[proxy] enabled", { target: PROXY_TARGET, @@ -1013,8 +1032,10 @@ if (PROXY_TARGET) { } // 2) Build upstream URL - const tail = req.path.slice(PROXY_PREFIX.length) || "/"; + const rawTail = req.path.slice(PROXY_PREFIX.length) || "/"; + const tail = sanitizeProxyPath(rawTail); const urlObj = new URL(tail, PROXY_TARGET); + for (const [k, v] of Object.entries(req.query)) { if (Array.isArray(v)) v.forEach(x => urlObj.searchParams.append(k, String(x))); else if (v != null) urlObj.searchParams.append(k, String(v)); From 97f934ae78c1ab3d6216175a75f46f826a02787b Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 07:08:13 -0800 Subject: [PATCH 2/9] chore(security): harden gatewaystack SSRF, logging, and rate limits --- apps/gateway-server/src/app.ts | 34 ++- apps/gateway-server/src/routes/testEcho.ts | 33 +++ .../explicabl/src/webhooks/auth0LogWebhook.ts | 167 ++++++++--- packages/proxyabl/src/tool-gateway.ts | 259 +++++++++++++----- 4 files changed, 375 insertions(+), 118 deletions(-) diff --git a/apps/gateway-server/src/app.ts b/apps/gateway-server/src/app.ts index 95c48b1..e5d11de 100644 --- a/apps/gateway-server/src/app.ts +++ b/apps/gateway-server/src/app.ts @@ -15,17 +15,6 @@ import { testEchoRoutes } from "./routes/testEcho"; import rateLimit from "express-rate-limit"; -/** - * GatewayStack reference server - * - * This app wires together all 6 governance layers: - * 1) Identifiabl – authentication / identity (JWT → req.user) - * 2) Transformabl – request transformation (PII redaction, classification) - * 3) Validatabl – scopes, RBAC, protected resource metadata - * 4) Limitabl – per-identity rate limiting / usage control - * 5) Proxyabl – provider routing, tool / MCP gateway - * 6) Explicabl – health, logging, audit, webhooks - */ export function buildApp(env: NodeJS.ProcessEnv) { // ----------------------------- // Boot & configuration @@ -36,17 +25,26 @@ export function buildApp(env: NodeJS.ProcessEnv) { const DEMO = env.DEMO_MODE === "true"; // OIDC / OAuth config (used by Identifiabl + Validatabl) - const OAUTH_ISSUER = (DEMO ? env.OAUTH_ISSUER_DEMO : env.OAUTH_ISSUER) || ""; - const OAUTH_AUDIENCE = (DEMO ? env.OAUTH_AUDIENCE_DEMO : env.OAUTH_AUDIENCE)!; - const OAUTH_JWKS_URI = (DEMO ? env.OAUTH_JWKS_URI_DEMO : env.OAUTH_JWKS_URI) || env.JWKS_URI_FALLBACK ||""; - const OAUTH_SCOPES = ((DEMO ? env.OAUTH_SCOPES_DEMO : env.OAUTH_SCOPES) || "openid email profile") + const OAUTH_ISSUER = + (DEMO ? env.OAUTH_ISSUER_DEMO : env.OAUTH_ISSUER) || ""; + const OAUTH_AUDIENCE = + (DEMO ? env.OAUTH_AUDIENCE_DEMO : env.OAUTH_AUDIENCE) || ""; + const OAUTH_JWKS_URI = + (DEMO ? env.OAUTH_JWKS_URI_DEMO : env.OAUTH_JWKS_URI) || + env.JWKS_URI_FALLBACK || + ""; + const OAUTH_SCOPES = ( + (DEMO ? env.OAUTH_SCOPES_DEMO : env.OAUTH_SCOPES) || + "openid email profile" + ) .trim() .split(/\s+/); + // 🔐 Avoid logging sensitive values directly console.log("[boot] DEMO_MODE=%s", DEMO); - console.log("[boot] OAUTH_ISSUER=%s", OAUTH_ISSUER); - console.log("[boot] OAUTH_AUDIENCE=%s", OAUTH_AUDIENCE); - console.log("[boot] OAUTH_JWKS_URI=%s", OAUTH_JWKS_URI); + console.log("[boot] OAUTH_ISSUER_SET=%s", Boolean(OAUTH_ISSUER)); + console.log("[boot] OAUTH_AUDIENCE_SET=%s", Boolean(OAUTH_AUDIENCE)); + console.log("[boot] OAUTH_JWKS_URI_SET=%s", Boolean(OAUTH_JWKS_URI)); // Fail fast in demo if required vars are missing if (DEMO && (!OAUTH_ISSUER || !OAUTH_AUDIENCE)) { diff --git a/apps/gateway-server/src/routes/testEcho.ts b/apps/gateway-server/src/routes/testEcho.ts index 88a3647..eda6014 100644 --- a/apps/gateway-server/src/routes/testEcho.ts +++ b/apps/gateway-server/src/routes/testEcho.ts @@ -2,6 +2,7 @@ import express from "express"; import type { Request, Response, NextFunction } from "express"; import { createRemoteJWKSet, jwtVerify } from "jose"; +import rateLimit from "express-rate-limit"; // Read allowlist from env the same way your handler does (or import your util if you have one) function parseAllowlist(env: NodeJS.ProcessEnv): Record { @@ -13,6 +14,31 @@ function parseAllowlist(env: NodeJS.ProcessEnv): Record { } } +// ---- Rate limiting for test echo routes ---- +function keyFromReq(req: any): string { + const xf = req.get?.("x-forwarded-for"); + if (typeof xf === "string" && xf.length > 0) { + return xf.split(",")[0].trim(); + } + return req.ip || "unknown"; +} + +function createTestEchoLimiter(env: NodeJS.ProcessEnv): express.RequestHandler { + const windowMs = +(env.TEST_ECHO_WINDOW_MS || 60_000); // 1 minute default + const max = +(env.TEST_ECHO_MAX_PER_WINDOW || 60); // 60 reqs/min default + + const limiter = rateLimit({ + windowMs, + max, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: keyFromReq, + }); + + // bridge any subtle @types/express mismatch + return limiter as unknown as express.RequestHandler; +} + function requireJwt(env: NodeJS.ProcessEnv) { const issuer = env.AUTH0_ISSUER || env.OIDC_ISSUER; const audience = env.AUTH0_AUDIENCE || env.OIDC_AUDIENCE; @@ -72,8 +98,13 @@ function requireScope(env: NodeJS.ProcessEnv) { export function testEchoRoutes(env: NodeJS.ProcessEnv) { const r = express.Router(); + + // 🚦 Rate limit *before* heavy JWT verification + r.use(createTestEchoLimiter(env)); + r.use(requireJwt(env)); r.use(requireScope(env)); + r.get("/echo", (req, res) => { res.json({ ok: true, @@ -83,6 +114,7 @@ export function testEchoRoutes(env: NodeJS.ProcessEnv) { route: "GET:/__test__/echo", }); }); + r.post("/echo", (req, res) => { res.json({ ok: true, @@ -93,5 +125,6 @@ export function testEchoRoutes(env: NodeJS.ProcessEnv) { body: req.body, }); }); + return r; } diff --git a/packages/explicabl/src/webhooks/auth0LogWebhook.ts b/packages/explicabl/src/webhooks/auth0LogWebhook.ts index 7b883bd..c98ad75 100644 --- a/packages/explicabl/src/webhooks/auth0LogWebhook.ts +++ b/packages/explicabl/src/webhooks/auth0LogWebhook.ts @@ -1,6 +1,7 @@ // packages/explicabl-express/src/webhooks/auth0-log-webhook.ts import * as express from "express"; import type { Request, Response } from "express"; +import rateLimit from "express-rate-limit"; /** * Env @@ -23,19 +24,71 @@ const TOOL_SCOPES = (process.env.TOOL_SCOPES || process.env.REQUIRED_SCOPES || " .map((s) => s.trim()) .filter(Boolean); +// ---- Rate limiting for the Auth0 log webhook ---- +const LOG_WEBHOOK_WINDOW_MS = +(process.env.LOG_WEBHOOK_WINDOW_MS || 60_000); // 1 minute +const LOG_WEBHOOK_MAX_PER_WINDOW = +(process.env.LOG_WEBHOOK_MAX_PER_WINDOW || 60); + +// Use IP / x-forwarded-for as the key +const webhookKeyFromReq = (req: any): string => { + const xf = req.get?.("x-forwarded-for"); + if (typeof xf === "string" && xf.length > 0) { + return xf.split(",")[0].trim(); + } + return req.ip || "unknown"; +}; + +const auth0WebhookLimiter = rateLimit({ + windowMs: LOG_WEBHOOK_WINDOW_MS, + max: LOG_WEBHOOK_MAX_PER_WINDOW, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: webhookKeyFromReq, +}); + +// bridge the type mismatch between express-rate-limit and this package’s express types +const auth0WebhookLimiterMw: express.RequestHandler = + auth0WebhookLimiter as unknown as express.RequestHandler; + +// ✅ NEW: sanitize client_id coming from logs / Auth0 +function sanitizeMgmtClientId(id: string): string { + if (typeof id !== "string") { + throw new Error("invalid_client_id_type"); + } + // Auth0 client_ids are URL-safe base64-ish: letters, digits, - and _ + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error("invalid_client_id_format"); + } + return id; +} + +// ✅ NEW: centralize / validate domain + path → prevents SSRF via MGMT_DOMAIN/path tricks +function auth0MgmtUrl(path: string): string { + const domain = (MGMT_DOMAIN || "").trim(); + if (!domain) { + throw new Error("MGMT_DOMAIN_not_set"); + } + // only allow normal hostnames + if (!/^[a-zA-Z0-9.-]+$/.test(domain)) { + throw new Error(`invalid_MGMT_DOMAIN:${domain}`); + } + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `https://${domain}${normalizedPath}`; +} + type OAuthTokenRes = { access_token: string; token_type?: string; expires_in?: number }; async function getMgmtToken(): Promise { - const r = await fetch(`https://${MGMT_DOMAIN}/oauth/token`, { + const r = await fetch(auth0MgmtUrl("/oauth/token"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "client_credentials", client_id: MGMT_CLIENT_ID, client_secret: MGMT_CLIENT_SECRET, - audience: `https://${MGMT_DOMAIN}/api/v2/` + audience: auth0MgmtUrl("/api/v2/") }) } as any); + if (!r.ok) throw new Error(`mgmt_token_http_${r.status}`); const j = (await r.json()) as OAuthTokenRes; if (!j?.access_token) throw new Error("mgmt_token_missing_access_token"); @@ -49,21 +102,35 @@ async function promoteClient(mgmtToken: string, clientId: string) { token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"] }; - const r = await fetch(`https://${MGMT_DOMAIN}/api/v2/clients/${clientId}`, { - method: "PATCH", - headers: { "content-type": "application/json", authorization: `Bearer ${mgmtToken}` }, - body: JSON.stringify(patchBody) - } as any); + + const safeClientId = sanitizeMgmtClientId(clientId); + + const r = await fetch( + auth0MgmtUrl(`/api/v2/clients/${encodeURIComponent(safeClientId)}`), + { + method: "PATCH", + headers: { + "content-type": "application/json", + authorization: `Bearer ${mgmtToken}` + }, + body: JSON.stringify(patchBody) + } as any + ); + if (!r.ok) { - const txt = await r.text(); - throw new Error(`promote_http_${r.status}:${txt}`); + // don’t include full response body (avoids leaking potentially sensitive info) + console.error("[promoteClient:error]", { + status: r.status, + client_id_suffix: safeClientId.slice(-5), + }); + throw new Error(`promote_http_${r.status}`); } } async function enableGoogleForClient(mgmtToken: string, clientId: string) { // 1) find the connection id for google-oauth2 const rc = await fetch( - `https://${MGMT_DOMAIN}/api/v2/connections?name=${encodeURIComponent(GOOGLE_CONNECTION_NAME)}`, + auth0MgmtUrl(`/api/v2/connections?name=${encodeURIComponent(GOOGLE_CONNECTION_NAME)}`), { headers: { authorization: `Bearer ${mgmtToken}` } } as any ); if (!rc.ok) throw new Error(`conn_lookup_http_${rc.status}`); @@ -75,15 +142,24 @@ async function enableGoogleForClient(mgmtToken: string, clientId: string) { const enabled = new Set(Array.isArray(conn.enabled_clients) ? conn.enabled_clients : []); enabled.add(clientId); - const rp = await fetch(`https://${MGMT_DOMAIN}/api/v2/connections/${conn.id}`, { - method: "PATCH", - headers: { "content-type": "application/json", authorization: `Bearer ${mgmtToken}` }, - body: JSON.stringify({ enabled_clients: Array.from(enabled) }) - } as any); + const rp = await fetch( + auth0MgmtUrl(`/api/v2/connections/${encodeURIComponent(conn.id)}`), + { + method: "PATCH", + headers: { + "content-type": "application/json", + authorization: `Bearer ${mgmtToken}` + }, + body: JSON.stringify({ enabled_clients: Array.from(enabled) }) + } as any + ); if (!rp.ok) { - const txt = await rp.text(); - throw new Error(`conn_patch_http_${rp.status}:${txt}`); - } + console.error("[enableGoogleForClient:error]", { + status: rp.status, + connection_id_suffix: conn.id.slice(-5), + }); + throw new Error(`conn_patch_http_${rp.status}`); +} } async function ensureClientGrant( @@ -95,7 +171,7 @@ async function ensureClientGrant( const aud = (audience || "").trim(); if (!aud || !scopes.length) return; - const r = await fetch(`https://${MGMT_DOMAIN}/api/v2/client-grants`, { + const r = await fetch(auth0MgmtUrl("/api/v2/client-grants"), { method: "POST", headers: { "content-type": "application/json", @@ -109,11 +185,19 @@ async function ensureClientGrant( } as any); if (!r.ok) { - const txt = await r.text(); - throw new Error(`client_grant_http_${r.status}:${txt}`); + console.error("[ensureClientGrant:error]", { + status: r.status, + client_id_suffix: clientId.slice(-5), + audience_suffix: aud.slice(-10) + }); + throw new Error(`client_grant_http_${r.status}`); } - console.log("[dcr] created client grant", { clientId, audience: aud, scopes }); + console.log("[dcr] created client grant", { + client_id_suffix: clientId.slice(-5), + audience_suffix: aud.slice(-10), + scope_count: scopes.length, + }); } // ---- log helpers ---- @@ -143,14 +227,15 @@ function isDcrEventRaw(ev: any): boolean { } async function findNewestChatGPTClientId(mgmtToken: string): Promise { const url = - `https://${MGMT_DOMAIN}/api/v2/clients` + + auth0MgmtUrl("/api/v2/clients") + "?is_global=false&per_page=10&sort=created_at:-1&fields=client_id,name,created_at,app_type,grant_types,token_endpoint_auth_method&include_fields=true"; + const r = await fetch(url, { headers: { authorization: `Bearer ${mgmtToken}` } } as any); if (!r.ok) { - const txt = await r.text(); - console.warn("[dcr] clients list failed", r.status, txt); + console.warn("[dcr] clients list failed", { status: r.status }); return null; } + const arr = (await r.json()) as Array; const now = Date.now(); for (const c of arr) { @@ -281,11 +366,11 @@ async function handleAuth0LogWebhook(req: Request, res: Response) { await enableGoogleForClient(mgmtToken, cid); await ensureClientGrant(mgmtToken, cid, OAUTH_AUDIENCE, TOOL_SCOPES); - console.log("[dcr] promoted+enabled+granted", { - client_id: cid, - audience: OAUTH_AUDIENCE, - scopes: TOOL_SCOPES - }); + console.log("[dcr] promoted+enabled+granted", { + client_id_suffix: cid.slice(-5), + has_audience: Boolean(OAUTH_AUDIENCE), + scope_count: TOOL_SCOPES.length, + }); } res.status(200).json({ ok: true, promoted: dcrEvents.length }); @@ -307,14 +392,18 @@ async function handleAuth0LogWebhook(req: Request, res: Response) { export function auth0LogsWebhook() { const r = express.Router(); // accept both text and JSON (Auth0 sometimes sends text/json variants) - r.post("/auth0-log-webhook", express.text({ type: "*/*", limit: "2mb" }), (req, res) => { - // If content-type was JSON, req.body is a string only when text middleware captured it. - // Try to parse JSON if it looks like JSON; otherwise leave as-is and handler will try again. - if (typeof req.body === "string") { - const looksJson = req.body.trim().startsWith("{") || req.body.trim().startsWith("["); - (req as any).body = looksJson ? req.body : req.body; // handler will parse if needed + r.post( + "/auth0-log-webhook", + auth0WebhookLimiterMw, // ✅ use the shimmed middleware + express.text({ type: "*/*", limit: "2mb" }), + (req, res) => { + if (typeof req.body === "string") { + const looksJson = + req.body.trim().startsWith("{") || req.body.trim().startsWith("["); + (req as any).body = looksJson ? req.body : req.body; + } + return handleAuth0LogWebhook(req, res); } - return handleAuth0LogWebhook(req, res); - }); + ); return r; -} +} \ No newline at end of file diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 962c9dc..2c563e2 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -2,6 +2,8 @@ import { Router } from "express"; import type { Request, Response } from "express"; import { randomUUID, createHmac } from "crypto"; +import rateLimit from "express-rate-limit"; + // ---------- Config -------- const FUNCTIONS_BASE = (process.env.FUNCTIONS_BASE ?? @@ -29,6 +31,49 @@ const OAUTH_SCOPES = (process.env.OAUTH_SCOPES ?? "openid email profile").trim() const GATEWAY_HMAC_SECRET = process.env.GATEWAY_HMAC_SECRET || "dev-only-change-me"; const APP_ORIGIN = process.env.APP_ORIGIN || "*"; +// --------- Rate limiting (DoS protection) --------- +const RATE_WINDOW_MS = +(process.env.RATE_WINDOW_MS || 60_000); // 1 minute +const TOOL_MAX_PER_WINDOW = +(process.env.TOOL_MAX_PER_WINDOW || 60); +const MCP_MAX_PER_WINDOW = +(process.env.MCP_MAX_PER_WINDOW || 60); +const WEBHOOK_MAX_PER_WINDOW = +(process.env.WEBHOOK_MAX_PER_WINDOW || 30); + +// Loosen the param type to avoid cross-package @types/express mismatch +const keyFromReq = (req: any): string => { + const xf = req.get?.("x-forwarded-for"); + if (typeof xf === "string" && xf.length > 0) { + return xf.split(",")[0].trim(); + } + return req.ip || "unknown"; +}; + + +// General limiter for tool calls (REST + MCP) +const toolLimiter = rateLimit({ + windowMs: RATE_WINDOW_MS, + max: TOOL_MAX_PER_WINDOW, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: keyFromReq, +}); + +// Slightly stricter/different one for MCP if you want (or reuse toolLimiter) +const mcpLimiter = rateLimit({ + windowMs: RATE_WINDOW_MS, + max: MCP_MAX_PER_WINDOW, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: keyFromReq, +}); + +// Auth0 log webhook limiter – low volume but protects you if someone guesses the URL +const webhookLimiter = rateLimit({ + windowMs: RATE_WINDOW_MS, + max: WEBHOOK_MAX_PER_WINDOW, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: keyFromReq, +}); + // Map tool -> scopes it requires // Example JSON: {"chatWithEmbeddingsv3":["app.write"],"listEvents":["app.read"]} const DEFAULT_TOOL_SCOPES: Record = { @@ -66,10 +111,10 @@ const REQUIRED_SCOPES = Array.from(new Set(Object.values(TOOL_SCOPES).flat())); function logCfg() { console.log("[cfg]", { OAUTH_ISSUER, - OAUTH_AUDIENCE, - JWKS_URI_FALLBACK, + OAUTH_AUDIENCE: OAUTH_AUDIENCE ? "[set]" : null, + JWKS_URI_FALLBACK: JWKS_URI_FALLBACK ? "[set]" : null, FUNCTIONS_BASE, - APP_ORIGIN + APP_ORIGIN, }); } logCfg(); @@ -88,12 +133,20 @@ function logAuthShape(prefix: string, req: Request) { hasAuth, tokenShape, len, req.path, req.method); } +const DEBUG_AUTH = process.env.DEBUG_AUTH === "1"; + function logJwtClaims(prefix: string, token: string) { + if (!DEBUG_AUTH) return; const dbg = decodeJwtUnsafe(token); - console.log(`[auth:${prefix}] iss=%s aud=%s scope=%s perms=%s`, - dbg?.iss, JSON.stringify(dbg?.aud), dbg?.scope, JSON.stringify(dbg?.permissions)); + console.log(`[auth:${prefix}] iss=%s aud_count=%s has_scope=%s has_permissions=%s`, + dbg?.iss, + Array.isArray(dbg?.aud) ? dbg.aud.length : (dbg?.aud ? 1 : 0), + Boolean(dbg?.scope), + Array.isArray(dbg?.permissions) && dbg.permissions.length > 0 + ); } + function b64urlDecodeToJson(s: string) { try { s += "=".repeat((4 - (s.length % 4)) % 4); @@ -128,6 +181,26 @@ function requireScopes(have: string[], need: string[]) { } } +function sanitizeFunctionName(name: string): string { + // Only allow normal Cloud Function-style names: letters, digits, dash, underscore + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + const err: any = new Error("INVALID_TOOL_NAME"); + err.status = 400; + err.code = "INVALID_TOOL_NAME"; + throw err; + } + + // Optional but recommended: require the tool to be in TOOL_SCOPES + if (!TOOL_SCOPES[name]) { + const err: any = new Error(`UNKNOWN_TOOL:${name}`); + err.status = 404; + err.code = "UNKNOWN_TOOL"; + throw err; + } + + return name; +} + async function subjectToUid(sub: string, _email?: string): Promise { return `auth0:${sub}`; } @@ -414,16 +487,16 @@ async function handleMcp(req: Request, res: Response) { return; } - // protected: call a tool + // protected: call a tool if (rpc.method === "tools/call") { - const name = rpc.params?.name; + const nameRaw = rpc.params?.name; const args = rpc.params?.arguments ?? {}; - console.log("[mcp] tools/call name=%s", name); + console.log("[mcp] tools/call name=%s", nameRaw); logAuthShape("mcp.tools/call", req); - if (typeof name !== "string" || !TOOL_SCOPES[name]) { - res.status(404).json(jsonRpcError(rpc.id ?? null, -32601, `Unknown tool: ${name}`)); + if (typeof nameRaw !== "string" || !TOOL_SCOPES[nameRaw]) { + res.status(404).json(jsonRpcError(rpc.id ?? null, -32601, `Unknown tool: ${nameRaw}`)); return; } @@ -433,16 +506,34 @@ async function handleMcp(req: Request, res: Response) { if (!hasAuth || tokenShape !== "jwt") { const www = buildWwwAuthenticate(req) + ", error=\"invalid_token\""; res.setHeader("WWW-Authenticate", www); - console.warn("[mcp] tools/call → 401 (non-JWT token)", { hasAuth, tokenShape, name }); + console.warn("[mcp] tools/call → 401 (non-JWT token)", { hasAuth, tokenShape, name: nameRaw }); res.status(401).json(jsonRpcError(rpc.id ?? null, -32001, "Unauthorized")); return; } } + // ✅ Sanitize & validate the function name before using it anywhere security-sensitive + let toolName: string; + try { + toolName = sanitizeFunctionName(nameRaw); + } catch (e: any) { + const status = Number(e?.status) || 400; + res.status(status).json( + jsonRpcError( + rpc.id ?? null, + -32602, + e?.message || "Invalid tool name", + { code: e?.code || "INVALID_TOOL_NAME" } + ) + ); + return; + } + let uid: string, gatewaySig: string; try { - const verified = await verifyBearerAndScopes(req, name); - uid = verified.uid; gatewaySig = verified.gatewaySig; + const verified = await verifyBearerAndScopes(req, toolName); + uid = verified.uid; + gatewaySig = verified.gatewaySig; } catch (e: any) { if (e?.www) res.setHeader("WWW-Authenticate", e.www); else res.setHeader("WWW-Authenticate", buildWwwAuthenticate(req)); @@ -452,7 +543,7 @@ async function handleMcp(req: Request, res: Response) { } const requestId = req.header("x-request-id") || randomUUID(); - const url = `${FUNCTIONS_BASE}/${name}`; + const url = `${FUNCTIONS_BASE}/${toolName}`; const r = await fetch(url, { method: "POST", headers: { @@ -482,6 +573,7 @@ async function handleMcp(req: Request, res: Response) { return; } + res.status(400).json(jsonRpcError(rpc.id ?? null, -32601, `Method not found: ${rpc.method}`)); } @@ -499,8 +591,8 @@ export async function wellKnownOauthProtectedResource(req: Request, res: Respons }); console.log("[wk.cfg]", { issuer: OAUTH_ISSUER, - audience: OAUTH_AUDIENCE || null, - jwks_uri_fallback: JWKS_URI_FALLBACK, + audience: OAUTH_AUDIENCE ? "[set]" : null, + jwks_uri_fallback: JWKS_URI_FALLBACK ? "[set]" : null, required_scopes: REQUIRED_SCOPES, }); @@ -609,7 +701,7 @@ export async function toolGatewayImpl(req: Request, res: Response): Promise { - const r = await fetch(`https://${MGMT_DOMAIN}/oauth/token`, { + const r = await fetch(auth0MgmtUrl("/oauth/token"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "client_credentials", client_id: MGMT_CLIENT_ID, client_secret: MGMT_CLIENT_SECRET, - audience: `https://${MGMT_DOMAIN}/api/v2/` + audience: auth0MgmtUrl("/api/v2/") }) } as any); - if (!r.ok) throw new Error(`mgmt_token_http_${r.status}`); - const j = (await r.json()) as OAuthTokenRes; // <-- typed - if (!j?.access_token) throw new Error("mgmt_token_missing_access_token"); + + if (!r.ok) { + throw new Error(`mgmt_token_http_${r.status}`); + } + + const j = (await r.json()) as OAuthTokenRes; + if (!j?.access_token) { + throw new Error("mgmt_token_missing_access_token"); + } return j.access_token; } - // Helper: patch client → first-party + public + grants async function promoteClient(mgmtToken: string, clientId: string) { const patchBody = { - app_type: "regular_web", // or "native" if you prefer + app_type: "regular_web", is_first_party: true, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"] }; - const r = await fetch(`https://${MGMT_DOMAIN}/api/v2/clients/${clientId}`, { - method: "PATCH", - headers: { "content-type": "application/json", authorization: `Bearer ${mgmtToken}` }, - body: JSON.stringify(patchBody) - } as any); + + const safeClientId = sanitizeMgmtClientId(clientId); + + const r = await fetch( + auth0MgmtUrl(`/api/v2/clients/${encodeURIComponent(safeClientId)}`), + { + method: "PATCH", + headers: { + "content-type": "application/json", + authorization: `Bearer ${mgmtToken}` + }, + body: JSON.stringify(patchBody) + } as any + ); + if (!r.ok) { - const txt = await r.text(); - throw new Error(`promote_http_${r.status}:${txt}`); + // Don't dump the body; just log status + client_id suffix + console.error("[promoteClient:error]", { + status: r.status, + client_id_suffix: safeClientId.slice(-5), + }); + throw new Error(`promote_http_${r.status}`); } } @@ -778,8 +927,11 @@ async function enableGoogleForClient(mgmtToken: string, clientId: string) { body: JSON.stringify({ enabled_clients: Array.from(enabled) }) } as any); if (!rp.ok) { - const txt = await rp.text(); - throw new Error(`conn_patch_http_${rp.status}:${txt}`); + console.error("[enableGoogleForClient:error]", { + status: rp.status, + client_id_suffix: clientId.slice(-5), + }); + throw new Error(`conn_patch_http_${rp.status}`); } } @@ -826,8 +978,7 @@ async function findNewestChatGPTClientId(mgmtToken: string): Promise; @@ -914,23 +1065,6 @@ export async function auth0LogWebhook(req: Request, res: Response) { try { parsed = JSON.parse(parsed); } catch { /* ignore */ } } const events: any[] = Array.isArray(parsed) ? (parsed as any[]) : [parsed as any]; - - - // DEBUG: show shape after unwrap so we can see fields - try { - const sample = (events || []).slice(0, 3).map((ev: any) => { - const e = unwrap(ev); - return { - type: e?.type, - desc: e?.description, - path: evtPath(e) || undefined, - method: e?.details?.request?.method || e?.http?.method, - hasRespClientId: !!e?.details?.response?.body?.client_id - }; - }); - console.log("[webhook:sample]", sample); - } catch { /* ignore */ } - const dcrEvents = events.filter(isDcrEventRaw); if (dcrEvents.length === 0) { @@ -977,9 +1111,10 @@ export async function auth0LogWebhook(req: Request, res: Response) { } res.status(200).json({ ok: true, promoted: dcrEvents.length }); } catch (e: any) { - console.error("[dcr:error]", e?.message || e); - res.status(500).json({ ok: false, error: e?.message || String(e) }); + console.error("[dcr:error]", { message: e?.message || String(e) }); + res.status(500).json({ ok: false, error: "dcr_failed" }); } + } // ---- Express Router wrapper: preserves your exact paths/handlers ---- @@ -1085,10 +1220,12 @@ toolGatewayRouter.get("/.well-known/oauth-protected-resource", wellKnownOauthPro toolGatewayRouter.get("/.well-known/openid-configuration", toolGatewayImpl); toolGatewayRouter.get("/.well-known/oauth-authorization-server", toolGatewayImpl); -// MCP JSON-RPC + tool POSTs -toolGatewayRouter.post("/", toolGatewayImpl); -toolGatewayRouter.post("/mcp", toolGatewayImpl); -toolGatewayRouter.options("/mcp", toolGatewayImpl); +// MCP JSON-RPC + tool POSTs (rate limited) +toolGatewayRouter.post("/", toolLimiter as any, toolGatewayImpl); +toolGatewayRouter.post("/mcp", mcpLimiter as any, toolGatewayImpl); +toolGatewayRouter.options("/mcp", mcpLimiter as any, toolGatewayImpl); + +// Auth0 Log Stream webhook (DCR auto-fix, rate limited too) +toolGatewayRouter.post("/auth0/logs", webhookLimiter as any, auth0LogWebhook); + -// Auth0 Log Stream webhook (DCR auto-fix) -toolGatewayRouter.post("/auth0/logs", auth0LogWebhook); From 701abc09642f0c8de52d3033f56841fd96864e9d Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 07:11:27 -0800 Subject: [PATCH 3/9] Auth0 webhook sensitive log fix --- packages/explicabl/src/webhooks/auth0LogWebhook.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/explicabl/src/webhooks/auth0LogWebhook.ts b/packages/explicabl/src/webhooks/auth0LogWebhook.ts index c98ad75..3c03102 100644 --- a/packages/explicabl/src/webhooks/auth0LogWebhook.ts +++ b/packages/explicabl/src/webhooks/auth0LogWebhook.ts @@ -185,21 +185,23 @@ async function ensureClientGrant( } as any); if (!r.ok) { + // ✅ only log non-sensitive status, no clientId/audience derivatives console.error("[ensureClientGrant:error]", { status: r.status, - client_id_suffix: clientId.slice(-5), - audience_suffix: aud.slice(-10) + has_audience: Boolean(aud), + scope_count: scopes.length, }); throw new Error(`client_grant_http_${r.status}`); } + // ✅ success log also avoids any clientId/audience values console.log("[dcr] created client grant", { - client_id_suffix: clientId.slice(-5), - audience_suffix: aud.slice(-10), scope_count: scopes.length, + has_audience: Boolean(aud), }); } + // ---- log helpers ---- function unwrap(ev: any): any { return (ev && typeof ev === "object" && ev.data && typeof ev.data === "object") ? ev.data : ev; From f20d708053b574eeaef1246bf95402206ce9c06f Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 07:21:44 -0800 Subject: [PATCH 4/9] fix: harden tool gateway proxy (SSRF guard) and add rate limiting to discovery endpoints --- packages/proxyabl/src/tool-gateway.ts | 165 ++++++++++++++++---------- 1 file changed, 105 insertions(+), 60 deletions(-) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 2c563e2..7e70f7c 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -36,6 +36,7 @@ const RATE_WINDOW_MS = +(process.env.RATE_WINDOW_MS || 60_000); // 1 minute const TOOL_MAX_PER_WINDOW = +(process.env.TOOL_MAX_PER_WINDOW || 60); const MCP_MAX_PER_WINDOW = +(process.env.MCP_MAX_PER_WINDOW || 60); const WEBHOOK_MAX_PER_WINDOW = +(process.env.WEBHOOK_MAX_PER_WINDOW || 30); +const DISCOVERY_MAX_PER_WINDOW = +(process.env.DISCOVERY_MAX_PER_WINDOW || 120); // Loosen the param type to avoid cross-package @types/express mismatch const keyFromReq = (req: any): string => { @@ -74,6 +75,15 @@ const webhookLimiter = rateLimit({ keyGenerator: keyFromReq, }); +// New: discovery limiter for well-known endpoints & metadata +const discoveryLimiter = rateLimit({ + windowMs: RATE_WINDOW_MS, + max: DISCOVERY_MAX_PER_WINDOW, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: keyFromReq, +}); + // Map tool -> scopes it requires // Example JSON: {"chatWithEmbeddingsv3":["app.write"],"listEvents":["app.read"]} const DEFAULT_TOOL_SCOPES: Record = { @@ -1127,98 +1137,133 @@ const PROXY_PREFIX = process.env.PROXY_PREFIX || "/proxy"; // e.g. "/proxy const PROXY_INJECT_HEADER = process.env.PROXY_INJECT_HEADER || "";// e.g. "X-User-Id" const PROXY_INJECT_QUERY = process.env.PROXY_INJECT_QUERY || "";// e.g. "userId" +// Parse and validate PROXY_TARGET once to avoid SSRF-style host smuggling +const PROXY_TARGET_URL: URL | null = (() => { + if (!PROXY_TARGET) return null; + try { + const u = new URL(PROXY_TARGET); + if (!/^https?:$/.test(u.protocol)) { + console.error("[proxy] PROXY_TARGET must be http or https"); + return null; + } + if (!/^[a-zA-Z0-9.-]+$/.test(u.hostname)) { + console.error("[proxy] invalid PROXY_TARGET hostname:", u.hostname); + return null; + } + return u; + } catch (e) { + console.error("[proxy] invalid PROXY_TARGET:", PROXY_TARGET, e); + return null; + } +})(); + function sanitizeProxyPath(rawTail: string): string { let p = rawTail || "/"; // Normalize: ensure we always treat it as a path, not a URL - // Strip repeated leading slashes but keep one p = "/" + p.replace(/^\/+/, ""); - // Block attempts to smuggle in absolute URLs: - // - "//evil.com" (network-path reference) - // - "http://..." or "https://..." or any "scheme://" + // Block attempts to smuggle in absolute URLs or schemes if (p.startsWith("//") || p.includes("://")) { const err: any = new Error("INVALID_UPSTREAM_PATH"); err.status = 400; throw err; } + // Optional: avoid .. path traversal + if (p.includes("..")) { + const err: any = new Error("INVALID_UPSTREAM_PATH"); + err.status = 400; + throw err; + } + return p; } -if (PROXY_TARGET) { +if (PROXY_TARGET_URL) { console.log("[proxy] enabled", { - target: PROXY_TARGET, + target: PROXY_TARGET_URL.toString(), prefix: PROXY_PREFIX, injectHeader: PROXY_INJECT_HEADER || null, injectQuery: PROXY_INJECT_QUERY || null, }); toolGatewayRouter.all(`${PROXY_PREFIX}/*`, async (req: Request, res: Response) => { - try { - // 1) Verify the Bearer token (or rely on your global gate; if so, you can skip this) - const payload = await verifyBearer(req); - const sub = String(payload.sub || ""); - if (!sub) { - const e: any = new Error("TOKEN_NO_SUB"); - e.status = 401; - e.www = buildWwwAuthenticate(req) + ', error="invalid_token"'; - throw e; - } + try { + const payload = await verifyBearer(req); + const sub = String(payload.sub || ""); + if (!sub) { + const e: any = new Error("TOKEN_NO_SUB"); + e.status = 401; + e.www = buildWwwAuthenticate(req) + ', error="invalid_token"'; + throw e; + } - // 2) Build upstream URL - const rawTail = req.path.slice(PROXY_PREFIX.length) || "/"; - const tail = sanitizeProxyPath(rawTail); - const urlObj = new URL(tail, PROXY_TARGET); + const rawTail = req.path.slice(PROXY_PREFIX.length) || "/"; + const tail = sanitizeProxyPath(rawTail); - for (const [k, v] of Object.entries(req.query)) { - if (Array.isArray(v)) v.forEach(x => urlObj.searchParams.append(k, String(x))); - else if (v != null) urlObj.searchParams.append(k, String(v)); - } - if (PROXY_INJECT_QUERY) urlObj.searchParams.set(PROXY_INJECT_QUERY, sub); + // Use the validated PROXY_TARGET_URL as the base + const urlObj = new URL(tail, PROXY_TARGET_URL); - // 3) Prepare headers/body - const headers: Record = {}; - for (const [k, v] of Object.entries(req.headers)) { - if (["host","connection","content-length","transfer-encoding","authorization"].includes(k)) continue; - headers[k] = Array.isArray(v) ? v.join(", ") : (v as string); - } - if (PROXY_INJECT_HEADER) headers[PROXY_INJECT_HEADER] = sub; + for (const [k, v] of Object.entries(req.query)) { + if (Array.isArray(v)) v.forEach(x => urlObj.searchParams.append(k, String(x))); + else if (v != null) urlObj.searchParams.append(k, String(v)); + } + if (PROXY_INJECT_QUERY) urlObj.searchParams.set(PROXY_INJECT_QUERY, sub); - const method = req.method.toUpperCase(); - const body = !["GET","HEAD"].includes(method) && req.body ? JSON.stringify(req.body) : undefined; - if (body) headers["content-type"] = headers["content-type"] || "application/json"; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (["host","connection","content-length","transfer-encoding","authorization"].includes(k)) continue; + headers[k] = Array.isArray(v) ? v.join(", ") : (v as string); + } + if (PROXY_INJECT_HEADER) headers[PROXY_INJECT_HEADER] = sub; - // 4) Do the upstream fetch (consider a timeout) - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), +(process.env.PROXY_TIMEOUT_MS || 5000)); - const upstream = await fetch(urlObj.toString(), { method, headers, body, signal: controller.signal } as any); - clearTimeout(t); + const method = req.method.toUpperCase(); + const body = !["GET","HEAD"].includes(method) && req.body ? JSON.stringify(req.body) : undefined; + if (body) headers["content-type"] = headers["content-type"] || "application/json"; - res.status(upstream.status); - upstream.headers.forEach((val, key) => { - if (!["content-length","transfer-encoding","connection"].includes(key)) res.setHeader(key, val); - }); - const buf = Buffer.from(await upstream.arrayBuffer()); - res.end(buf); // ✅ IMPORTANT: end the response - } catch (e: any) { - if (e?.name === "AbortError") { - return res.status(502).json({ error: "upstream_timeout" }); + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), +(process.env.PROXY_TIMEOUT_MS || 5000)); + const upstream = await fetch(urlObj.toString(), { method, headers, body, signal: controller.signal } as any); + clearTimeout(t); + + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + if (!["content-length","transfer-encoding","connection"].includes(key)) res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.end(buf); + } catch (e: any) { + if (e?.name === "AbortError") { + return res.status(502).json({ error: "upstream_timeout" }); + } + if (e?.www) res.setHeader("WWW-Authenticate", e.www); + const status = Number(e?.status) || 502; + console.error("[proxy:error]", e?.message || e); + res.status(status).json({ error: "proxy_failed", detail: String(e?.message || e) }); } - if (e?.www) res.setHeader("WWW-Authenticate", e.www); - const status = Number(e?.status) || 502; - console.error("[proxy:error]", e?.message || e); - res.status(status).json({ error: "proxy_failed", detail: String(e?.message || e) }); - } -}); + }); } else { - console.log("[proxy] disabled (set PROXY_TARGET to enable)"); + console.log("[proxy] disabled (set valid PROXY_TARGET to enable)"); } -// Well-knowns -toolGatewayRouter.get("/.well-known/oauth-protected-resource", wellKnownOauthProtectedResource); -toolGatewayRouter.get("/.well-known/openid-configuration", toolGatewayImpl); -toolGatewayRouter.get("/.well-known/oauth-authorization-server", toolGatewayImpl); +// Well-knowns (rate-limited) +toolGatewayRouter.get( + "/.well-known/oauth-protected-resource", + discoveryLimiter as any, + wellKnownOauthProtectedResource +); +toolGatewayRouter.get( + "/.well-known/openid-configuration", + discoveryLimiter as any, + toolGatewayImpl +); +toolGatewayRouter.get( + "/.well-known/oauth-authorization-server", + discoveryLimiter as any, + toolGatewayImpl +); + // MCP JSON-RPC + tool POSTs (rate limited) toolGatewayRouter.post("/", toolLimiter as any, toolGatewayImpl); From 65e262da57221b1c7d61414295e65071f84998e4 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 07:29:48 -0800 Subject: [PATCH 5/9] tighten proxy SSRF defenses and redact client IDs in logs --- packages/proxyabl/src/tool-gateway.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 7e70f7c..ac71c46 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -1157,6 +1157,25 @@ const PROXY_TARGET_URL: URL | null = (() => { } })(); +const PROXY_ALLOWED_PATHS = (process.env.PROXY_ALLOWED_PATHS || "/").split(",") + .map(p => p.trim()) + .filter(Boolean); + + function enforceProxyPathAllowlist(p: string): void { + // Require the path to start with one of the allowed prefixes + const ok = PROXY_ALLOWED_PATHS.some(prefix => { + // normalize: ensure prefix has leading slash + const pref = prefix.startsWith("/") ? prefix : `/${prefix}`; + return p === pref || p.startsWith(pref.endsWith("/") ? pref : pref + "/"); + }); + + if (!ok) { + const err: any = new Error("PROXY_PATH_NOT_ALLOWED"); + err.status = 400; + throw err; + } +} + function sanitizeProxyPath(rawTail: string): string { let p = rawTail || "/"; @@ -1200,7 +1219,7 @@ if (PROXY_TARGET_URL) { } const rawTail = req.path.slice(PROXY_PREFIX.length) || "/"; - const tail = sanitizeProxyPath(rawTail); + const tail = sanitizeProxyPath(rawTail); enforceProxyPathAllowlist(tail); // Use the validated PROXY_TARGET_URL as the base const urlObj = new URL(tail, PROXY_TARGET_URL); From 1614c51ed08f5e77d88b1e6aaf414db3b7b28041 Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 10:07:04 -0800 Subject: [PATCH 6/9] Harden proxy SSRF handling and redact sensitive error logging --- packages/proxyabl/src/tool-gateway.ts | 64 ++++++++++++++++++++------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index ac71c46..46ce6b6 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -1127,15 +1127,14 @@ export async function auth0LogWebhook(req: Request, res: Response) { } -// ---- Express Router wrapper: preserves your exact paths/handlers ---- // ---- Express Router wrapper: preserves your exact paths/handlers ---- export const toolGatewayRouter = Router(); // ===== Simple authenticated proxy with user injection ===== -const PROXY_TARGET = process.env.PROXY_TARGET || "http://127.0.0.1:3333"; // e.g. "http://localhost:3333" -const PROXY_PREFIX = process.env.PROXY_PREFIX || "/proxy"; // e.g. "/proxy" -const PROXY_INJECT_HEADER = process.env.PROXY_INJECT_HEADER || "";// e.g. "X-User-Id" -const PROXY_INJECT_QUERY = process.env.PROXY_INJECT_QUERY || "";// e.g. "userId" +const PROXY_TARGET = process.env.PROXY_TARGET || "http://127.0.0.1:3333"; // e.g. "http://localhost:3333" +const PROXY_PREFIX = process.env.PROXY_PREFIX || "/proxy"; // e.g. "/proxy" +const PROXY_INJECT_HEADER = process.env.PROXY_INJECT_HEADER || ""; // e.g. "X-User-Id" +const PROXY_INJECT_QUERY = process.env.PROXY_INJECT_QUERY || ""; // e.g. "userId" // Parse and validate PROXY_TARGET once to avoid SSRF-style host smuggling const PROXY_TARGET_URL: URL | null = (() => { @@ -1157,21 +1156,33 @@ const PROXY_TARGET_URL: URL | null = (() => { } })(); -const PROXY_ALLOWED_PATHS = (process.env.PROXY_ALLOWED_PATHS || "/").split(",") +// Comma-separated list of allowed paths, e.g. "/api,/health" +const PROXY_ALLOWED_PATHS = (process.env.PROXY_ALLOWED_PATHS || "/") + .split(",") .map(p => p.trim()) .filter(Boolean); - function enforceProxyPathAllowlist(p: string): void { - // Require the path to start with one of the allowed prefixes +/** + * Throws if the path is not in the allowlist. + * - Exact match: "/foo" + * - Prefix match: "/foo/" matches "/foo/bar", "/foo/baz", etc. + */ +function enforceProxyPathAllowlist(p: string): void { + // Ensure leading slash + const path = p.startsWith("/") ? p : `/${p}`; + const ok = PROXY_ALLOWED_PATHS.some(prefix => { - // normalize: ensure prefix has leading slash const pref = prefix.startsWith("/") ? prefix : `/${prefix}`; - return p === pref || p.startsWith(pref.endsWith("/") ? pref : pref + "/"); + if (pref.endsWith("/")) { + return path === pref.slice(0, -1) || path.startsWith(pref); + } + return path === pref || path.startsWith(pref + "/"); }); if (!ok) { const err: any = new Error("PROXY_PATH_NOT_ALLOWED"); - err.status = 400; + err.status = 403; + err.code = "PROXY_PATH_NOT_ALLOWED"; throw err; } } @@ -1179,13 +1190,14 @@ const PROXY_ALLOWED_PATHS = (process.env.PROXY_ALLOWED_PATHS || "/").split(",") function sanitizeProxyPath(rawTail: string): string { let p = rawTail || "/"; - // Normalize: ensure we always treat it as a path, not a URL + // Normalize: ensure we always treat it as a *path*, not a URL p = "/" + p.replace(/^\/+/, ""); // Block attempts to smuggle in absolute URLs or schemes if (p.startsWith("//") || p.includes("://")) { const err: any = new Error("INVALID_UPSTREAM_PATH"); err.status = 400; + err.code = "INVALID_UPSTREAM_PATH"; throw err; } @@ -1193,6 +1205,7 @@ function sanitizeProxyPath(rawTail: string): string { if (p.includes("..")) { const err: any = new Error("INVALID_UPSTREAM_PATH"); err.status = 400; + err.code = "INVALID_UPSTREAM_PATH"; throw err; } @@ -1205,6 +1218,7 @@ if (PROXY_TARGET_URL) { prefix: PROXY_PREFIX, injectHeader: PROXY_INJECT_HEADER || null, injectQuery: PROXY_INJECT_QUERY || null, + allowedPaths: PROXY_ALLOWED_PATHS, }); toolGatewayRouter.all(`${PROXY_PREFIX}/*`, async (req: Request, res: Response) => { @@ -1218,12 +1232,15 @@ if (PROXY_TARGET_URL) { throw e; } + // Slice off the prefix and sanitize tail const rawTail = req.path.slice(PROXY_PREFIX.length) || "/"; - const tail = sanitizeProxyPath(rawTail); enforceProxyPathAllowlist(tail); + const tail = sanitizeProxyPath(rawTail); + enforceProxyPathAllowlist(tail); // ✅ allowlist check BEFORE constructing URL // Use the validated PROXY_TARGET_URL as the base const urlObj = new URL(tail, PROXY_TARGET_URL); + // Copy query params, then inject user id if configured for (const [k, v] of Object.entries(req.query)) { if (Array.isArray(v)) v.forEach(x => urlObj.searchParams.append(k, String(x))); else if (v != null) urlObj.searchParams.append(k, String(v)); @@ -1243,12 +1260,21 @@ if (PROXY_TARGET_URL) { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), +(process.env.PROXY_TIMEOUT_MS || 5000)); - const upstream = await fetch(urlObj.toString(), { method, headers, body, signal: controller.signal } as any); + + const upstream = await fetch(urlObj.toString(), { + method, + headers, + body, + signal: controller.signal, + } as any); + clearTimeout(t); res.status(upstream.status); upstream.headers.forEach((val, key) => { - if (!["content-length","transfer-encoding","connection"].includes(key)) res.setHeader(key, val); + if (!["content-length","transfer-encoding","connection"].includes(key)) { + res.setHeader(key, val); + } }); const buf = Buffer.from(await upstream.arrayBuffer()); res.end(buf); @@ -1258,14 +1284,18 @@ if (PROXY_TARGET_URL) { } if (e?.www) res.setHeader("WWW-Authenticate", e.www); const status = Number(e?.status) || 502; - console.error("[proxy:error]", e?.message || e); - res.status(status).json({ error: "proxy_failed", detail: String(e?.message || e) }); + + // (logging fix is in section 2 below) + console.error("[proxy:error]", { status, code: e?.code }); + + res.status(status).json({ error: "proxy_failed" }); } }); } else { console.log("[proxy] disabled (set valid PROXY_TARGET to enable)"); } + // Well-knowns (rate-limited) toolGatewayRouter.get( "/.well-known/oauth-protected-resource", From 4905c4b0ec1445825a3a8a2b1acfec1a711f1a2b Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 10:11:08 -0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20proxy=20SSRF=20false-positive=20?= =?UTF-8?q?=E2=80=94=20path=20is=20sanitized=20&=20allowlisted,=20base=20U?= =?UTF-8?q?RL=20validated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/proxyabl/src/tool-gateway.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 46ce6b6..ce0ca89 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -1261,6 +1261,9 @@ if (PROXY_TARGET_URL) { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), +(process.env.PROXY_TIMEOUT_MS || 5000)); + // codeql[js/server-side-request-forgery]: + // urlObj is built from a constant, validated PROXY_TARGET_URL and an allowlisted, sanitized path (tail), + // so SSRF is mitigated here. const upstream = await fetch(urlObj.toString(), { method, headers, From 3b459ab55e6231aa23ee2904bd5433bcb4d3565c Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 10:14:04 -0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20proxy=20SSRF=20false-positive=20?= =?UTF-8?q?=E2=80=94=20path=20is=20sanitized=20&=20allowlisted,=20base=20U?= =?UTF-8?q?RL=20validated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/proxyabl/src/tool-gateway.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index ce0ca89..0b3003c 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -1261,9 +1261,8 @@ if (PROXY_TARGET_URL) { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), +(process.env.PROXY_TIMEOUT_MS || 5000)); - // codeql[js/server-side-request-forgery]: - // urlObj is built from a constant, validated PROXY_TARGET_URL and an allowlisted, sanitized path (tail), - // so SSRF is mitigated here. + // codeql[js/request-forgery]: urlObj is built from a constant, validated PROXY_TARGET_URL + // and an allowlisted, sanitized path (tail), so SSRF is mitigated here. const upstream = await fetch(urlObj.toString(), { method, headers, From 0857974c08150c654f897300788558ae21fbd00e Mon Sep 17 00:00:00 2001 From: David Crowe Date: Fri, 5 Dec 2025 10:21:16 -0800 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20proxy=20SSRF=20false-positive=20?= =?UTF-8?q?=E2=80=94=20path=20is=20sanitized=20&=20allowlisted,=20base=20U?= =?UTF-8?q?RL=20validated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/proxyabl/src/tool-gateway.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 0b3003c..17be517 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -1237,7 +1237,7 @@ if (PROXY_TARGET_URL) { const tail = sanitizeProxyPath(rawTail); enforceProxyPathAllowlist(tail); // ✅ allowlist check BEFORE constructing URL - // Use the validated PROXY_TARGET_URL as the base + // codeql[js/request-forgery]: PROXY_TARGET_URL is constant/validated and `tail` is sanitized + allowlisted const urlObj = new URL(tail, PROXY_TARGET_URL); // Copy query params, then inject user id if configured @@ -1261,8 +1261,7 @@ if (PROXY_TARGET_URL) { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), +(process.env.PROXY_TIMEOUT_MS || 5000)); - // codeql[js/request-forgery]: urlObj is built from a constant, validated PROXY_TARGET_URL - // and an allowlisted, sanitized path (tail), so SSRF is mitigated here. + // codeql[js/request-forgery]: urlObj is safe (see comment above); SSRF is mitigated const upstream = await fetch(urlObj.toString(), { method, headers,