From 983aac342d79fb563f1ec270c6ec7b3b8b7e76af Mon Sep 17 00:00:00 2001 From: SarthakB11 Date: Sat, 9 May 2026 18:53:18 +0000 Subject: [PATCH] fix(server): redact sensitive env vars and headers from connection logs Closes #847 --- server/package.json | 3 +- server/src/index.ts | 10 ++++-- server/src/redact.ts | 48 ++++++++++++++++++++++++++ server/test/redact.test.ts | 69 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 server/src/redact.ts create mode 100644 server/test/redact.test.ts diff --git a/server/package.json b/server/package.json index 5d348912a..3beb97ca7 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,8 @@ "build": "tsc && shx cp -R static build", "start": "node build/index.js", "dev": "tsx watch --clear-screen=false src/index.ts", - "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL" + "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL", + "test": "node --import tsx --test test/*.test.ts" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/server/src/index.ts b/server/src/index.ts index bdfe49019..db6c389b5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,6 +28,7 @@ import express from "express"; import rateLimit from "express-rate-limit"; import { findActualExecutable } from "spawn-rx"; import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js"; +import { redactSensitiveEntries, redactQueryForLogging } from "./redact.js"; import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; @@ -430,7 +431,10 @@ const createTransport = async ( headerHolder?: ProxyHeaderHolder; }> => { const query = req.query; - console.log("Query parameters:", JSON.stringify(query)); + console.log( + "Query parameters:", + JSON.stringify(redactQueryForLogging(query)), + ); const transportType = query.transportType as string; @@ -461,7 +465,9 @@ const createTransport = async ( const headerHolder: ProxyHeaderHolder = { headers }; console.log( - `SSE transport: url=${url}, headers=${JSON.stringify(headers)}`, + `SSE transport: url=${url}, headers=${JSON.stringify( + redactSensitiveEntries(headers), + )}`, ); const transport = new SSEClientTransport(new URL(url), { diff --git a/server/src/redact.ts b/server/src/redact.ts new file mode 100644 index 000000000..e482c47b1 --- /dev/null +++ b/server/src/redact.ts @@ -0,0 +1,48 @@ +// Patterns matching env-var/header keys whose values may contain secrets. +// When logging, we keep the key (so users can see what was passed) but +// replace the value with `***` so tokens don't end up in stdout/log files. +export const SENSITIVE_KEY_PATTERNS: RegExp[] = [ + /token/i, + /secret/i, + /password/i, + /passwd/i, + /credential/i, + /api[-_]?key/i, + /(^|_)key($|_)/i, + /auth/i, + /session/i, + /private/i, + /^aws_/i, +]; + +export const REDACTED = "***"; + +export const isSensitiveKey = (key: string): boolean => + SENSITIVE_KEY_PATTERNS.some((re) => re.test(key)); + +export const redactSensitiveEntries = ( + obj: Record | null | undefined, +): Record => { + if (!obj) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = isSensitiveKey(k) ? REDACTED : v; + } + return out; +}; + +// Returns a copy of an Express query object with the `env` JSON value +// re-serialized with sensitive entries redacted, suitable for logging. +export const redactQueryForLogging = (q: unknown): unknown => { + if (!q || typeof q !== "object") return q; + const out: Record = { ...(q as Record) }; + if (typeof out.env === "string") { + try { + const parsed = JSON.parse(out.env); + out.env = redactSensitiveEntries(parsed); + } catch { + out.env = REDACTED; + } + } + return out; +}; diff --git a/server/test/redact.test.ts b/server/test/redact.test.ts new file mode 100644 index 000000000..035a990e9 --- /dev/null +++ b/server/test/redact.test.ts @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + redactSensitiveEntries, + redactQueryForLogging, +} from "../src/redact.js"; + +test("redactSensitiveEntries: redacts common secret-bearing env vars and keeps benign ones", () => { + const input = { + GITHUB_TOKEN: "ghp_xxx", + PATH: "/usr/bin", + AWS_ACCESS_KEY_ID: "AKIA...", + }; + assert.deepEqual(redactSensitiveEntries(input), { + GITHUB_TOKEN: "***", + PATH: "/usr/bin", + AWS_ACCESS_KEY_ID: "***", + }); +}); + +test("redactSensitiveEntries: bare KEY and API_KEY are redacted", () => { + assert.deepEqual(redactSensitiveEntries({ KEY: "k" }), { KEY: "***" }); + assert.deepEqual(redactSensitiveEntries({ API_KEY: "k" }), { + API_KEY: "***", + }); + assert.deepEqual(redactSensitiveEntries({ "api-key": "k" }), { + "api-key": "***", + }); +}); + +test("redactSensitiveEntries: word containing 'key' is NOT redacted (boundary)", () => { + // The boundary in /(^|_)key($|_)/i prevents naive substring matches like + // MONKEY, KEYBOARD, etc. from being flagged as secrets. + assert.deepEqual(redactSensitiveEntries({ MONKEY: "m" }), { MONKEY: "m" }); + assert.deepEqual(redactSensitiveEntries({ KEYBOARD: "k" }), { + KEYBOARD: "k", + }); +}); + +test("redactSensitiveEntries: Authorization header is redacted", () => { + assert.deepEqual(redactSensitiveEntries({ Authorization: "Bearer x" }), { + Authorization: "***", + }); +}); + +test("redactQueryForLogging: env JSON is parsed and redacted entry-by-entry", () => { + const env = JSON.stringify({ PASSWORD: "p", PORT: "5432" }); + const out = redactQueryForLogging({ env, transport: "stdio" }) as Record< + string, + unknown + >; + assert.deepEqual(out.env, { PASSWORD: "***", PORT: "5432" }); + assert.equal(out.transport, "stdio"); +}); + +test("redactQueryForLogging: malformed env falls back to ***", () => { + const out = redactQueryForLogging({ env: "not-json" }) as Record< + string, + unknown + >; + assert.equal(out.env, "***"); +}); + +test("redactQueryForLogging: missing env passes through unchanged", () => { + assert.deepEqual(redactQueryForLogging({ transport: "sse" }), { + transport: "sse", + }); +});