fix(server): redact sensitive env vars and headers from connection logs - #1296
fix(server): redact sensitive env vars and headers from connection logs#1296SarthakB11 wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds log sanitization for sensitive environment variables and SSE headers.
Changes:
- Introduces reusable redaction helpers.
- Applies redaction to connection logs.
- Adds focused Node test coverage.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
server/src/redact.ts |
Implements key-based redaction. |
server/src/index.ts |
Sanitizes query and SSE header logs. |
server/test/redact.test.ts |
Tests redaction behavior. |
server/package.json |
Adds the test command. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| if (typeof out.env === "string") { | ||
| try { | ||
| const parsed = JSON.parse(out.env); | ||
| out.env = redactSensitiveEntries(parsed); | ||
| } catch { | ||
| out.env = REDACTED; | ||
| } | ||
| } |
There was a problem hiding this comment.
Agreed and fixed in 7ef9447.
redactQueryForLogging now routes env through a dedicated redactEnvForLogging:
- A non-string
env(Express yields an array for?env=a&env=band an object for?env[x]=y) is replaced with***rather than passed through. - A parsed value is only preserved when it is a flat string-to-string map (
isFlatStringMap: object, non-null, not an array, all values strings). An array, a scalar, or a nested payload such as{"SAFE":{"PASSWORD":"p"}}is redacted wholesale, since the shallow key-based redactor cannot reach inside it. - Malformed JSON still falls back to
***, as before.
Two new tests cover it: non-string env is redacted wholesale and env parsing to a non-flat value is redacted wholesale.
| @@ -0,0 +1,48 @@ | |||
| // Patterns matching env-var/header keys whose values may contain secrets. | |||
There was a problem hiding this comment.
Declining this one -- it is a process judgement rather than a code finding, and the premise is not quite right for this repo.
v1/main is the deprecated maintenance line and it does still accept security fixes, published straight from that branch to the v1-latest npm dist-tag. Redacting secrets out of proxy connection logs is exactly that class of change, so v1/main is the correct base; retargeting it to v2 would leave every v1 user on v1-latest with the leak.
Whether to land this PR (versus close it and have a maintainer reimplement from the issue) is a maintainer decision, not one to take from a review bot -- and a maintainer is driving this review loop deliberately. No code change.
| `SSE transport: url=${url}, headers=${JSON.stringify( | ||
| redactSensitiveEntries(headers), | ||
| )}`, |
There was a problem hiding this comment.
Agreed and fixed in 7ef9447.
You are right that a name-pattern check cannot cover this: getHttpHeaders honors x-custom-auth-header / x-custom-auth-headers, so the credential can arrive under any name the caller picks, and X-Foo matches none of the patterns.
The SSE log now uses a new redactHeadersForLogging, which is safe-by-default: every forwarded header value is replaced with *** unless its lowercased name is on a small known-non-credential allowlist (accept, which the proxy sets itself, and last-event-id, an SSE resumption cursor). Header names are kept, so the log still answers the question it was there to answer -- which headers are being forwarded -- without asserting anything about a value.
Covered by two new tests: one asserting Authorization, X-Foo, X-Access-Key, and mcp-protocol-version are all redacted, and one asserting the allowlisted pair is preserved.
Addresses two findings from Copilot review:
- `x-custom-auth-header(s)` lets a caller nominate an arbitrarily-named
header (`X-Foo`, `X-Access-Key`) as its credential carrier, so inferring
sensitivity from the header name could not protect it. The SSE transport
log now uses `redactHeadersForLogging`, which redacts every forwarded
header value except a small known-non-credential allowlist (`Accept`,
`Last-Event-ID`), keeping the names so the log still shows what is
being forwarded.
- `redactQueryForLogging` only handled a string `env` and assumed any
successfully parsed value was a flat map. Express yields an array for
`?env=a&env=b` and an object for `?env[x]=y`, and a nested payload such
as `{"SAFE":{"PASSWORD":"p"}}` slipped past the shallow key-based
redactor. `env` is now redacted wholesale unless it parses to a flat
string-to-string map. This log runs before transport validation, so a
malformed request could otherwise disclose its own payload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review — round 1 responsePushed 1.
2. 3. Tests: 7 → 11, all passing ( Not changed (no finding raised, keeping the diff tight): |
| if (!obj) return {}; | ||
| const out: Record<string, unknown> = {}; | ||
| for (const [k, v] of Object.entries(obj)) { | ||
| out[k] = isSensitiveKey(k) ? REDACTED : v; |
There was a problem hiding this comment.
Agreed and fixed in f3e7feb. This is the right call, and it generalizes: DATABASE_URL and AZURE_STORAGE_CONNECTION_STRING are exactly the shape a name-based denylist cannot catch, because the credential is inside the value while the name looks like configuration. Env-var names are arbitrary, so the pattern list could never be complete.
Rather than extend the heuristic, the denylist is gone: SENSITIVE_KEY_PATTERNS, isSensitiveKey, and redactSensitiveEntries are all removed. env now has every value replaced with *** and only its keys preserved, so the log still shows which env vars are being passed to the spawned process without asserting anything about any of them. A nested value is redacted at the top level rather than descended into, and a non-string / unparseable / non-object env is still redacted wholesale.
| ): Record<string, string> => { | ||
| const out: Record<string, string> = {}; | ||
| for (const [k, v] of Object.entries(headers)) { | ||
| out[k] = NON_SENSITIVE_HEADERS.has(k.toLowerCase()) ? v : REDACTED; |
There was a problem hiding this comment.
Agreed and fixed in f3e7feb — you are right, and I should have caught that the allowlist was reachable by the very mechanism the previous round was about.
Confirmed both bypasses against getHttpHeaders:
x-custom-auth-header: last-event-idpluslast-event-id: <secret>writesheaders["last-event-id"] = <secret>, which the allowlist then passed through verbatim.x-custom-auth-header: acceptwritesheaders["accept"] = <secret>; the proxy separately setsheaders["Accept"] = "text/event-stream", and since object keys are case-sensitive both survive — so the lowercase one was logged.
The allowlist is removed entirely. redactHeadersForLogging now replaces every value unconditionally and keeps only the names, which is the diagnostic the log was actually for. A test asserts that Accept, lowercase accept, and last-event-id are all redacted alongside Authorization and an arbitrary X-Foo.
Addresses two further Copilot findings, both of which come down to the same root cause: neither header names nor env-var names are a reliable signal of sensitivity, so any name-based rule leaks. - The env denylist preserved values whose *names* look innocuous but whose *values* are credentials -- `DATABASE_URL=postgres://user:password@host/db`, `AZURE_STORAGE_CONNECTION_STRING=...`. Env-var names are arbitrary, so the pattern list could never be complete. - The header allowlist (`accept`, `last-event-id`) was defeatable. `getHttpHeaders` forwards whatever name arrives in `x-custom-auth-header(s)`, so a caller can nominate `last-event-id` as its credential carrier, or lowercase `accept` -- which survives alongside the proxy's own `Accept` property, since object keys are case-sensitive. Either value passed the allowlist and was logged. Both surfaces now redact every value unconditionally and keep only the keys, so the log still shows which headers and which env vars are being passed through without asserting anything about a value. This removes `SENSITIVE_KEY_PATTERNS`, `isSensitiveKey`, and `redactSensitiveEntries` entirely rather than extending the heuristic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review — round 2 responsePushed 4. 5.
The allowlist is gone. Net effect. Verified: 9 tests passing ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
server/package.json:28
- This adds a server-local test command, but neither the root
testscript (package.json:43) nor the PR workflow (.github/workflows/main.yml:29-40) invokes it. As a result, all of the new redaction tests can fail while CI remains green. Wirenpm test --workspace=server(or the equivalent working-directory step) into the repository test/CI path.
"test": "node --import tsx --test test/*.test.ts"
server/src/redact.ts:25
- The implementation now redacts every env value and exposes the parsed key map, but the PR description still specifies
SENSITIVE_KEY_PATTERNS, selective value redaction (including preservingPATH), re-serialization ofenv, and seven passing tests. Those are materially different logging semantics from this code and the nine tests in the diff; update the PR description and test plan to document the final behavior.
obj: Record<string, unknown>,
): Record<string, string> => {
const out: Record<string, string> = {};
for (const key of Object.keys(obj)) {
out[key] = REDACTED;
The redaction tests were added under `server/test/` with a server-local `test` script, but nothing invoked it: the root `test` script runs only prettier plus the client suite, and the PR workflow has steps for the client tests and the build only. The new tests could therefore fail while CI stayed green -- which for a security fix means the redaction could silently regress. Adds a `test-server` script alongside the existing `test-cli`, folds it into the root `test`, and adds a matching workflow step after the client tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review — round 3 responseNo new inline comments this round. The review body carried two suppressed comments, both valid and both now addressed. 6. 7. Verified: |
|
Thanks for this, @SarthakB11, and for the patience while it sat — this one had real work in it and deserves a real explanation. We're closing it. The finding is legitimateThe connection logs did print forwarded environment values and header values verbatim, and an MCP server config routinely carries API keys and tokens in exactly those places. The review round on this PR sharpened it considerably: it started as a denylist of secret-looking key names and ended as unconditional redaction of every env value and every forwarded header value, which is the correct shape — a name-pattern heuristic silently misses Why we're closing it anywayThe exposure here is a local one. These values are written to the console of the process the user started themselves, on their own machine, and are visible to whoever can already read that terminal or its captured output. That's a real hygiene problem — it matters for screen shares, pasted bug reports, and CI logs — but it is not a vulnerability that lets a remote party obtain anything they couldn't already.
Worth carrying forwardTwo things surfaced here that are worth keeping, and neither is v1-specific:
If you'd like to take either to v2, please open an issue describing it and a maintainer will pick it up from there. Three commits were pushed to this branch during review ( |
NOTE
Summary
The proxy logs the full incoming connection request to stdout in two places in
server/src/index.ts:console.log("Query parameters:", JSON.stringify(query)).query.envis a JSON-encoded map of process env vars supplied by the user, frequently containing*_TOKEN,*_KEY,*_SECRET,AWS_*,password, database URLs, and connection strings.SSE transport: url=..., headers=....headersregularly carriesAuthorization, andgetHttpHeadersalso forwards any header the caller nominates viax-custom-auth-header(s)— so the credential can arrive under an arbitrary name.Those values end up in the proxy's stdout and from there into terminals, shell history, log files, and screen recordings (the original scenario from #375). This PR sanitizes both log lines.
Change
Helpers live in a new
server/src/redact.ts, imported byserver/src/index.tsat both log sites.Both surfaces redact by default rather than by matching secret-looking names, and keep only the keys. A name is not a reliable signal of sensitivity on either one:
getHttpHeadersforwards whatever name arrives inx-custom-auth-header/x-custom-auth-headers, so a credential can land underX-Foo,X-Access-Key, or a name that an allowlist would treat as safe (last-event-id; or lowercaseaccept, which survives alongside the proxy's own case-sensitiveAcceptkey).DATABASE_URL=postgres://user:password@host/db,AZURE_STORAGE_CONNECTION_STRING=.... No pattern list can be complete.The API is two functions plus the
"***"sentinel:redactHeadersForLogging(headers)— replaces every header value with"***", preserving names.redactQueryForLogging(query)— clones the query and replaces the embeddedenvwith a redacted view: every value becomes"***"and only the env-var names are preserved. A non-stringenv(Express yields an array for?env=a&env=band an object for?env[x]=y), anenvthat is not valid JSON, and one that parses to an array or a scalar are each redacted wholesale to"***"rather than logged raw — this log runs before transport validation, so a malformed request must not be able to disclose its own payload. A nested value is redacted at the top level, not descended into.Both call sites log the redacted view only; the live
envandheadersobjects are still passed unchanged to the spawned transport /SSEClientTransport. Keys are kept so the log still answers the question it exists to answer — which env vars and which headers are being forwarded — while asserting nothing about any value.Scope notes
PATH. That is the deliberate trade: the previous name-based heuristic was demonstrably bypassable in both directions, and this only affects the log line, never what the spawned process receives.command/argslog a few lines below, and theurl=in the SSE log, are left alone. Both can carry secrets in principle and are the same class of issue, but they are out of scope for this PR and better tracked separately.redactHeadersForLoggingis available if that ever changes.Test plan
server/previously had no test runner. This PR adds a minimalnode:testsetup usingtsx(already inserver/devDependencies); zero new dependencies. New script:server/package.json"test": "node --import tsx --test test/*.test.ts".It is wired into CI: a
test-serverscript at the root (alongside the existingtest-cli), folded into the roottest, plus a Run server tests step in.github/workflows/main.ymlafter the client tests — so a redaction regression fails the build rather than passing unnoticed.server/test/redact.test.tscovers:Authorization, an arbitraryX-Foo,X-Access-Key,mcp-protocol-version, and the two former allowlist bypasses (Accept/ lowercaseaccept,last-event-id)PATHandDATABASE_URL, which a name-based denylist would have preservedenvfalls back to"***"instead of leaking the raw payloadenv(array, object) redacted wholesaleenvparsing to a non-object (array, scalar,null) redacted wholesaleenvpass through unchangedRun with
cd server && npm test(9 passed, 0 failed), ornpm run test-serverfrom the root.tsc --noEmitandprettier --checkare clean.