Skip to content

Commit 983aac3

Browse files
committed
fix(server): redact sensitive env vars and headers from connection logs
Closes #847
1 parent f18775a commit 983aac3

4 files changed

Lines changed: 127 additions & 3 deletions

File tree

server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"build": "tsc && shx cp -R static build",
2525
"start": "node build/index.js",
2626
"dev": "tsx watch --clear-screen=false src/index.ts",
27-
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
27+
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL",
28+
"test": "node --import tsx --test test/*.test.ts"
2829
},
2930
"devDependencies": {
3031
"@types/cors": "^2.8.19",

server/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import express from "express";
2828
import rateLimit from "express-rate-limit";
2929
import { findActualExecutable } from "spawn-rx";
3030
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
31+
import { redactSensitiveEntries, redactQueryForLogging } from "./redact.js";
3132
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
3233
import { fileURLToPath } from "url";
3334
import { dirname, join } from "path";
@@ -430,7 +431,10 @@ const createTransport = async (
430431
headerHolder?: ProxyHeaderHolder;
431432
}> => {
432433
const query = req.query;
433-
console.log("Query parameters:", JSON.stringify(query));
434+
console.log(
435+
"Query parameters:",
436+
JSON.stringify(redactQueryForLogging(query)),
437+
);
434438

435439
const transportType = query.transportType as string;
436440

@@ -461,7 +465,9 @@ const createTransport = async (
461465
const headerHolder: ProxyHeaderHolder = { headers };
462466

463467
console.log(
464-
`SSE transport: url=${url}, headers=${JSON.stringify(headers)}`,
468+
`SSE transport: url=${url}, headers=${JSON.stringify(
469+
redactSensitiveEntries(headers),
470+
)}`,
465471
);
466472

467473
const transport = new SSEClientTransport(new URL(url), {

server/src/redact.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Patterns matching env-var/header keys whose values may contain secrets.
2+
// When logging, we keep the key (so users can see what was passed) but
3+
// replace the value with `***` so tokens don't end up in stdout/log files.
4+
export const SENSITIVE_KEY_PATTERNS: RegExp[] = [
5+
/token/i,
6+
/secret/i,
7+
/password/i,
8+
/passwd/i,
9+
/credential/i,
10+
/api[-_]?key/i,
11+
/(^|_)key($|_)/i,
12+
/auth/i,
13+
/session/i,
14+
/private/i,
15+
/^aws_/i,
16+
];
17+
18+
export const REDACTED = "***";
19+
20+
export const isSensitiveKey = (key: string): boolean =>
21+
SENSITIVE_KEY_PATTERNS.some((re) => re.test(key));
22+
23+
export const redactSensitiveEntries = (
24+
obj: Record<string, unknown> | null | undefined,
25+
): Record<string, unknown> => {
26+
if (!obj) return {};
27+
const out: Record<string, unknown> = {};
28+
for (const [k, v] of Object.entries(obj)) {
29+
out[k] = isSensitiveKey(k) ? REDACTED : v;
30+
}
31+
return out;
32+
};
33+
34+
// Returns a copy of an Express query object with the `env` JSON value
35+
// re-serialized with sensitive entries redacted, suitable for logging.
36+
export const redactQueryForLogging = (q: unknown): unknown => {
37+
if (!q || typeof q !== "object") return q;
38+
const out: Record<string, unknown> = { ...(q as Record<string, unknown>) };
39+
if (typeof out.env === "string") {
40+
try {
41+
const parsed = JSON.parse(out.env);
42+
out.env = redactSensitiveEntries(parsed);
43+
} catch {
44+
out.env = REDACTED;
45+
}
46+
}
47+
return out;
48+
};

server/test/redact.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import {
5+
redactSensitiveEntries,
6+
redactQueryForLogging,
7+
} from "../src/redact.js";
8+
9+
test("redactSensitiveEntries: redacts common secret-bearing env vars and keeps benign ones", () => {
10+
const input = {
11+
GITHUB_TOKEN: "ghp_xxx",
12+
PATH: "/usr/bin",
13+
AWS_ACCESS_KEY_ID: "AKIA...",
14+
};
15+
assert.deepEqual(redactSensitiveEntries(input), {
16+
GITHUB_TOKEN: "***",
17+
PATH: "/usr/bin",
18+
AWS_ACCESS_KEY_ID: "***",
19+
});
20+
});
21+
22+
test("redactSensitiveEntries: bare KEY and API_KEY are redacted", () => {
23+
assert.deepEqual(redactSensitiveEntries({ KEY: "k" }), { KEY: "***" });
24+
assert.deepEqual(redactSensitiveEntries({ API_KEY: "k" }), {
25+
API_KEY: "***",
26+
});
27+
assert.deepEqual(redactSensitiveEntries({ "api-key": "k" }), {
28+
"api-key": "***",
29+
});
30+
});
31+
32+
test("redactSensitiveEntries: word containing 'key' is NOT redacted (boundary)", () => {
33+
// The boundary in /(^|_)key($|_)/i prevents naive substring matches like
34+
// MONKEY, KEYBOARD, etc. from being flagged as secrets.
35+
assert.deepEqual(redactSensitiveEntries({ MONKEY: "m" }), { MONKEY: "m" });
36+
assert.deepEqual(redactSensitiveEntries({ KEYBOARD: "k" }), {
37+
KEYBOARD: "k",
38+
});
39+
});
40+
41+
test("redactSensitiveEntries: Authorization header is redacted", () => {
42+
assert.deepEqual(redactSensitiveEntries({ Authorization: "Bearer x" }), {
43+
Authorization: "***",
44+
});
45+
});
46+
47+
test("redactQueryForLogging: env JSON is parsed and redacted entry-by-entry", () => {
48+
const env = JSON.stringify({ PASSWORD: "p", PORT: "5432" });
49+
const out = redactQueryForLogging({ env, transport: "stdio" }) as Record<
50+
string,
51+
unknown
52+
>;
53+
assert.deepEqual(out.env, { PASSWORD: "***", PORT: "5432" });
54+
assert.equal(out.transport, "stdio");
55+
});
56+
57+
test("redactQueryForLogging: malformed env falls back to ***", () => {
58+
const out = redactQueryForLogging({ env: "not-json" }) as Record<
59+
string,
60+
unknown
61+
>;
62+
assert.equal(out.env, "***");
63+
});
64+
65+
test("redactQueryForLogging: missing env passes through unchanged", () => {
66+
assert.deepEqual(redactQueryForLogging({ transport: "sse" }), {
67+
transport: "sse",
68+
});
69+
});

0 commit comments

Comments
 (0)