Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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), {
Expand Down
48 changes: 48 additions & 0 deletions server/src/redact.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null | undefined,
): Record<string, unknown> => {
if (!obj) return {};
const out: Record<string, unknown> = {};
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<string, unknown> = { ...(q as Record<string, unknown>) };
if (typeof out.env === "string") {
try {
const parsed = JSON.parse(out.env);
out.env = redactSensitiveEntries(parsed);
} catch {
out.env = REDACTED;
}
}
return out;
};
69 changes: 69 additions & 0 deletions server/test/redact.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});