Conversation
SEC-01. The root API key was landing in plaintext on disk at two places. internal/httpapi's access logger wrote `query`, `referer` and `path` verbatim for every request the router sees. `?apikey=` is an accepted credential source, the Web UI's SSE stream and the tray client both send the admin key that way, and the Web UI is opened as `/ui/?apikey=<KEY>` so same-origin requests carry it in the Referer too - so http.log held the credential. cmd/mcpproxy logged a freshly auto-generated key three times at Warn: as `api_key`, inside `web_ui_url`, and again on the config-save-failure path - so main.log held it as well. The log fields now go through internal/oauth, which already owns the repo's redaction rules: the query-parameter name rule was extracted out of RedactURLQueryParamsWith into redactRawQueryParams so there is still exactly one implementation, and two renderers are built on it for input that arrives from an untrusted client and must not be assumed to parse. The generated key is printed once to an interactive terminal and written to the config file; the log sink gets only the masked prefix. The E2E scripts read the key from the config file instead of grepping it out of the server log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying mcpproxy-docs with
|
| Latest commit: |
ac871ef
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1318a20f.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://fix-redact-apikey-from-logs.mcpproxy-docs.pages.dev |
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 35819500390 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ification httpLoggingMiddleware runs LogSafeRequestPath/LogSafeQueryString/ LogSafeRequestURL on every request's path, query and Referer, mounted at the chi router root before apiKeyAuthMiddleware. logSafeURLComponent splits its input on '/' and runs the full value-shaped detector (44 regex patterns plus an entropy pass) once per segment, so an unauthenticated request built from many minimal path segments (well inside the existing 1MB MaxHeaderBytes budget) turned one request into hundreds of thousands of detector passes — verified reproducible at ~800ms of CPU for a single request. Bound the cost at the input instead of the algorithm: cap path/query/URL to 4KB, on a rune boundary, before any redaction rule runs. A few KB is already generous for what an access log line needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second-lens review: fixed a pre-auth DoS amplification in the SEC-01 redaction pathVerified the reported finding against the code and it was genuine — reproduced it directly:
FixBounded the cost at the input rather than the algorithm: Added regression tests (
No other findings from this round — the one MUST FIX item above was the only one raised, and there were no SHOULD FIX items.
🤖 Generated with Claude Code |
…ents SEC-01 follow-up (PR #1350): LogSafeRequestPath, LogSafeQueryString and LogSafeRequestURL mask a credential by NAME (apikey=<value>) or by VENDOR SHAPE (ghp_..., sk-...), but mcpproxy's auto-generated admin API key is bare 64-character hex with no enclosing name and no vendor prefix. Hex's 4-bit-per-symbol ceiling also means a perfectly random hex string's Shannon entropy (max 4.0) never clears the value-shaped detector's 4.5 threshold, so neither existing rule can ever catch it, in any position, regardless of tuning. A live instance reproduced the key landing verbatim in main.log as a bare path segment (GET /api/v1/status/<key>). Fix: internal/oauth/logging.go gains redactKnownSecrets(s, secrets), an exact-value redaction pass run before the name/shape rules. The three renderers each take a trailing ...knownSecrets variadic. A shape rule for "a 64-hex-char path/query segment" was considered and rejected: this same API legitimately logs SHA-256 tool hashes and other 64-hex-char identifiers (activity/request ids) as diagnostic path segments, so a rule tight enough to name the key's format is equally tight around theirs. Exact-value match has zero false positives by construction and covers every shape (path, query, referer, fragment) at once, including a user-overridden admin key that need not be hex at all. internal/httpapi/server.go's currentAdminAPIKey() reads the live cfg.APIKey fresh from the controller (nil-safe when s.controller is nil) and threads it into all 13 oauth.LogSafe* call sites. Agent tokens (mcp_agt_ prefix) are handled differently: unlike the admin key they carry a distinctive vendor prefix, so internal/security/patterns gained an agentTokenPattern (mcp_agt_[0-9a-fA-F]{64}) instead of needing per-request secret plumbing. internal/oauth/round8_renderer_discovery_test.go's AST-based "every mask renderer is bound or exempted" discovery net is extended to recognize the new func(string, ...string) string shape. zcode (GLM-5.3) review round 1 applied: thread cfg.APIKey through handleAgentTokenAuth instead of a redundant currentAdminAPIKey() call; widen the agent-token pattern to accept uppercase hex (ValidateTokenFormat already does via hex.DecodeString); tighten two doc comments that overstated what the fixed-length regex and "bare hex" framing guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SEC-01 follow-up: admin key surviving as a bare path/query/referer segmentLive verification (and a direct repro against still writes the key verbatim to Root causeNot a missed call site — the renderers themselves can't catch this shape. mcpproxy's auto-generated admin key (
The fix: exact-value redaction, not a shape ruleI considered a shape rule (e.g. "redact a full path/query segment that is exactly 64 hex chars") and rejected it. This same API logs SHA-256 tool hashes and other 64-hex-char identifiers (activity/request ids) as legitimate, diagnostically useful path segments. A shape rule tight enough to name the admin key's format (64 lowercase hex, no delimiters) is exactly as tight as what a tool hash or request id looks like — there is no regex that distinguishes "the admin key" from "a SHA-256 digest that happens to sit in a path." Loosening the rule to be safe just reopens false positives on ordinary log lines. Instead, Why exact-value over shape:
False-positive guard: Agent tokens — checked, already covered differentlyAgent tokens ( Tests (TDD — written first, confirmed failing before the fix)
zcode (GLM-5.3) review round — findings appliedOne read-only review round on
Verification
Co-Authored-By: Claude Opus 5 noreply@anthropic.com |
What
SEC-01: the root admin API key was written in plaintext to log files on disk, at two places.
internal/httpapi's access logger wrotequery,refererandpathverbatim for every request the router sees (it is mounted at the router root, so/events,/ui/and the whole API).?apikey=is an accepted credential source, the Web UI's SSE stream and the tray client both send the admin key that way, and the Web UI is opened as/ui/?apikey=<KEY>so same-origin requests put it in theRefereras well — the credential was landing inhttp.log.cmd/mcpproxylogged a freshly auto-generated key three times at Warn level: asapi_key, embedded inweb_ui_url, and once more on the config-save-failure path — somain.logheld it too.How
Log fields go through
internal/oauth, which already owns the repo's redaction rules. The query-parameter name rule was extracted out ofRedactURLQueryParamsWithintoredactRawQueryParams, so there is still exactly one implementation of it, and two renderers are built on it for input that arrives from an untrusted client:LogSafeQueryStringfor a barer.URL.RawQuery, andLogSafeRequestPath/LogSafeRequestURLfor the path and the Referer. None of them callsurl.Parse: a request's URL is not required to parse (an HTAB is legal inside a header value, a literal#is legal in a request target), and every parse-dependent path fell back to a regex that has noapikeyrule. They decompose by hand and redact userinfo, fragment, query and each path segment in its own right.The auto-generated key is printed once to stderr when stderr is an interactive terminal, and is written to the config file as before. The log sink gets
maskAPIKey(key), aweb_ui_urlbuilt with the key already masked, and the config path. Never stdout — an empty or:0listen address selects the stdio MCP transport.Least-surprising behaviour, decided here: on a non-terminal stderr (launchd, systemd, CI) the raw key is withheld even when the config save failed, because a redirected stream is as persistent as a log file; the banner and the Warn line name the remedy instead (
MCPPROXY_API_KEY, or make the path writable). A failed save does not abort startup — that would be a larger breaking change than this fix should carry.Scripts:
scripts/test-api-e2e.shused to obtain the key by grepping"api_key"out of the redirected server log. Both extraction functions now read it from the config file withjq, the wayscripts/dev-server-edition.shalready did, andscripts/test-extract-api-key.shtests the new contract.?apikey=keeps working exactly as before; removing that credential channel is a separate breaking change.Verified
httpapiandcmd/mcpproxytests were written first and failed on the pre-fix code with the raw key visible in the captured log fields../scripts/test-api-e2e.sh— 0 failures, including the server-edition audit-log instance.go test -raceoninternal/oauth,internal/httpapi,cmd/mcpproxy,internal/transport,internal/upstream/...,internal/runtime/....golangci-lintpasses (bare and--build-tags server).?apikey=, a malformedReferer, a#?apikey=fragment, hash routing and a key in the path; the raw key appears in no file under~/Library/Logs/mcpproxyafterwards, and every request still authenticates.codex gpt-5.6-sol, 8 rounds, clean verdict on the last one.Known boundary
A credential whose parameter name cannot be decoded and normalised on its own —
?<KEY>with no=,api%6bey%3d<KEY>,/ui%2Fapi%6bey=<KEY>— is only seen by the value-shaped detector, which does not recognise a bare hex string. mcpproxy never reads its credential from any of those shapes, and the gap underneath is repo-wide; it is pinned inTestLogSafeQueryString_DocumentedNameRuleBoundaryrather than guessed at here.Follow-up, out of scope:
internal/observability/tracing.goputsr.URL.String()into thehttp.urlspan attribute. Tracing is off by default and spans go to a collector rather than a log file, but it is the same credential.🤖 Generated with Claude Code