Skip to content

fix(security): compare the admin API key in constant time - #1341

Open
Dumbris wants to merge 1 commit into
mainfrom
fix/constant-time-key-compare
Open

Dumbris wants to merge 1 commit into
mainfrom
fix/constant-time-key-compare

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 22, 2026

Copy link
Copy Markdown
Member

What

Compare the global admin API key in constant time, and delete two copies of an unreachable fallback in generateAPIKey.

  • New auth.ConstantTimeEqual (internal/auth/compare.go) wraps crypto/subtle.ConstantTimeCompare on []byte and, deliberately, returns false when either side is empty.
  • Four admin-key comparison sites now use it:
    • internal/httpapi/server.goauthenticateExplicitToken (X-API-Key / ?apikey=)
    • internal/httpapi/server.goauthenticateBearer
    • internal/server/server.gomcpAuthMiddleware (the /mcp surface)
    • internal/server/server.go — the pprof gate
  • generateAPIKey in internal/config/config.go and cmd/mcpproxy-tray/main.go loses its fmt.Sprintf("mcpproxy_%d"/"tray_%d", time.Now().UnixNano()) fallback. Output shape is unchanged (32 random bytes, 64 hex chars).

Why

== on strings returns at the first differing byte, so rejection time leaks a prefix of the configured key to anyone who can measure it. The server-edition OIDC provider already uses subtle.ConstantTimeCompare (internal/serveredition/auth/oidc_provider.go); the main auth paths were the outlier.

The brief named two sites; there were four. /mcp and the pprof gate carry the same weakness, so they are fixed here rather than left for a follow-up — it is the same one-line change.

The deleted generateAPIKey fallback was unreachable, not a live vulnerability. crypto/rand.Read is documented since Go 1.24 as never returning an error and always filling the buffer; on a failure of the system source it terminates the process with a runtime fatal rather than returning. This module is go 1.26.0, so that branch could not execute and no predictable key was ever observable from it. It is removed because nothing in the code said so and a reader could not tell. generateAPIKey() string keeps its signature — returning an error would ripple into EnsureAPIKey() and its two callers for no gain.

The empty-string rejection in the helper is load-bearing: subtle.ConstantTimeCompare([]byte(""), []byte("")) returns 1, so a bare swap that dropped the surrounding token != "" / cfg.APIKey != "" guards would make an absent credential authenticate as admin. Folding the check into the helper means call sites cannot make that mistake.

No behavior change: same accept/reject verdict at every site, agent-token (mcp_agt_) prefix routing still runs ahead of the admin compare, and Spec 107 credential precedence is untouched.

How verified

  • Failing-first: internal/httpapi/constant_time_key_compare_guard_test.go is an AST guard (precedent: internal/config/latent_symbols_guard_test.go) that fails on any ==/!= against .APIKey outside an emptiness guard — it listed all four sites before the fix. internal/auth/compare_test.go failed to compile against the missing helper.
  • go test -race ./internal/auth/... ./internal/httpapi/... ./internal/config/... and the server-edition race run with the unit-tests.yml skip regex over serveredition, config, oauth, server, httpapi, storage — all green.
  • Both golangci-lint v2 passes (bare and --build-tags server) clean for the touched files.
  • Real instance on an isolated port with a scratch data dir: 16/16 checks — correct / wrong-same-length / prefix-only / empty credential across X-API-Key, Authorization: Bearer and ?apikey=, plus the pprof gate and a /mcp initialize with the admin key.
  • Cross-model review: codex exec --model gpt-5.6-sol.

🤖 Generated with Claude Code

Go's `==` on strings returns at the first differing byte, so the time a
request takes to be rejected leaks a prefix of the configured admin key to
anyone who can measure it. The server-edition OIDC provider already uses
crypto/subtle for its nonce check; the main auth paths were the outlier.

Add auth.ConstantTimeEqual (subtle.ConstantTimeCompare on []byte) and route
all four admin-key comparison sites through it:

  - internal/httpapi/server.go authenticateExplicitToken (X-API-Key, ?apikey=)
  - internal/httpapi/server.go authenticateBearer
  - internal/server/server.go mcpAuthMiddleware (the /mcp surface)
  - internal/server/server.go pprof gate

The helper rejects an empty candidate or secret itself. That is load-bearing:
subtle.ConstantTimeCompare([]byte(""), []byte("")) returns 1, so a bare swap
that dropped the surrounding `token != ""` / `cfg.APIKey != ""` guards would
make an absent credential authenticate as admin.

Also delete the time-based fallback in both copies of generateAPIKey
(internal/config, cmd/mcpproxy-tray). That branch was unreachable, not a live
vulnerability: crypto/rand.Read is documented since Go 1.24 as never returning
an error, terminating the process on a source failure instead, and this module
is go 1.26.0. It is removed because nothing in the code said so. Key shape is
unchanged (32 random bytes, 64 hex chars) and the signature stays
`generateAPIKey() string`, so EnsureAPIKey and its callers are untouched.

No behavior change: same accept/reject verdict at every site, agent-token
prefix routing still runs ahead of the admin compare, Spec 107 credential
precedence untouched.

Tests: an AST guard that fails on any `==`/`!=` against .APIKey outside an
emptiness guard (it listed all four sites before this change), the
ConstantTimeEqual contract including both-empty, a behavior table over all
three REST credential sources, and a generateAPIKey shape lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: d9589da
Status: ✅  Deploy successful!
Preview URL: https://bff54196.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-constant-time-key-compar.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/server.go 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/constant-time-key-compare

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (22 MB)
  • smart-mcp-proxymcpproxy-goPOQHPK.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 35745844810 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris

Dumbris commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

Second-lens review (SEC-05): no changes applied.

Re-verified the one open finding from the zcode review round:

The AST guard (internal/httpapi/constant_time_key_compare_guard_test.go) only walks internal/httpapi/server.go and internal/server/server.go, and only flags a direct .APIKey selector on either side of ==/!=. It would miss a compare added via a locally-aliased variable, or added in a different file/package.

Confirmed this is accurate but not a current gap: a repo-wide grep for .APIKey next to ==/!= turns up no fifth attacker-facing comparison site outside the four already routed through auth.ConstantTimeEqual.

Considered widening the guard to walk the whole repo, but that's not the small/low-risk change it looks like: two legitimate non-attacker-facing .APIKey equality comparisons already exist outside the two guarded files —

  • internal/config/config.go:2240c.APIKey != envAPIKey (reconciling the config-file key against an env-var override at startup, both trusted values)
  • internal/runtime/config_hotreload.go:138oldCfg.APIKey != newCfg.APIKey (diffing old vs. new trusted config on hot-reload, not a request-facing auth check)

A repo-wide walk would false-positive on both immediately, so doing this safely means classifying "attacker input vs. secret" comparisons apart from "trusted config vs. trusted config" ones across the codebase (or hand-maintaining an allowlist) — real design work, not a mechanical extension. Alias-tracking (key := cfg.APIKey; token == key) would need actual dataflow analysis on top of that. Leaving this as a follow-up rather than bolting on a guard that either misses aliasing anyway or breaks CI on legitimate config code.

No behavior or test changes in this pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants