Skip to content

fix(security): require admin auth for /metrics and stop logging the API key (SEC-07) - #1344

Open
Dumbris wants to merge 2 commits into
mainfrom
fix/protect-metrics-endpoint
Open

Dumbris wants to merge 2 commits into
mainfrom
fix/protect-metrics-endpoint

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 22, 2026

Copy link
Copy Markdown
Member

What

/metrics was registered on the bare chi router, outside the /api/v1 group that carries apiKeyAuthMiddleware, so it answered unauthenticated callers with fleet-wide tool-usage counters and API topology. It now sits behind the same credential chain as /events, plus an admin gate.

The Web UI's API service also logged the first 8 characters of the admin key to the devtools console — on import, on setAPIKey, on every request, and when opening the SSE stream. The system store logged it again on connect, and the SSE onerror handler logged the raw error event, whose target is the EventSource, leaking the whole key through its ?apikey= URL. All removed.

Why

Security finding SEC-07. Two decisions worth calling out:

  • Admin-only, not just authenticated. apiKeyAuthMiddleware alone still admits agent tokens (mcp_agt_), and a scope-restricted agent must not read fleet-wide aggregates — the same leak class as the finding. The route adds requireAdminReadMiddleware, a thin wrapper over the existing requireAdminRead rule, so one mux keeps one definition of "not admin".
  • Health probes stay open. /healthz, /livez, /health, /readyz, /ready are liveness/readiness probes and remain unauthenticated.

Operator-visible change: existing Prometheus scrapers break until they send the key. observability.metrics.enabled defaults to false, so most installs are untouched. docs/features/observability.md now documents the header and a scrape config using authorization: { credentials: ... }, which mcpproxy accepts as Authorization: Bearer <api key>. getAPIKeyPreview() is kept — it still backs the on-screen preview in the auth modal.

How verified

  • New internal/httpapi/metrics_auth_test.go: 401 with no credential, 401 with a wrong key, 200 with X-API-Key and with Bearer, 403 for a real mcp_agt_ token through the real handleAgentTokenAuth, and all five health aliases still open. Built on a controller returning a real *config.Config so it cannot pass vacuously through the middleware's not-a-config passthrough.
  • internal/server/e2e_metrics_test.go flips from asserting an unauthenticated 200 to 401-then-authenticated-200 against the real binary.
  • New frontend/tests/unit/api-key-not-logged.spec.ts drives the singleton, the store's connect path and its onerror handler under console spies; each case was confirmed to fail against the pre-fix source.
  • Live instance on an isolated port/data dir: anon /metrics → 401, X-API-Key → 200 with the uptime gauge, Bearer → 200, a real agent token → 403, all five probes → 200 unauthenticated, Unix socket → 200.
  • Both golangci-lint v2 passes (bare and --build-tags server), internal/httpapi + internal/config + internal/server under -race, full frontend suite (1328 tests) and vue-tsc.
  • Cross-model review: codex exec --model gpt-5.6-sol, 3 rounds, final verdict APPROVE. Round 1 and 2 each found a genuine surviving console leak (the system store's key preview, then the raw onerror event) — both fixed with coverage. I rejected two out-of-scope findings; see below.

Follow-ups (deliberately not in this PR)

  • The admin key is persisted in localStorage; moving to a session-cookie flow is a larger UX change.
  • httpLoggingMiddleware writes r.URL.RawQuery verbatim, so an ?apikey= credential on any route lands in http.log. Pre-existing, shared middleware, affects /events on every page load today.
  • Activity.vue logs raw activity payloads, which can carry upstream tool-call credentials. Different subsystem and data class.

🤖 Generated with Claude Code

…PI key (SEC-07)

/metrics was registered on the bare chi router, outside the /api/v1 group that
carries apiKeyAuthMiddleware, so any caller that could reach the listener could
scrape fleet-wide tool-usage counters and API topology unauthenticated.

It now carries the same credential chain as /events plus an admin gate:
apiKeyAuthMiddleware alone still admits agent tokens (mcp_agt_), and a
scope-restricted agent must not read fleet-wide aggregates. Scrapers
authenticate with the global API key via X-API-Key or Authorization: Bearer
(the latter is what Prometheus' `authorization: {credentials: ...}` block
sends). The tray keeps its Unix-socket bypass.

The liveness and readiness probes (/healthz, /livez, /health, /readyz, /ready)
stay unauthenticated by design.

On the frontend, the API service logged the first 8 characters of the admin key
to the devtools console on import, on setAPIKey, on every request and when
opening the SSE stream; the system store logged it again on connect, and the
SSE onerror handler logged the raw error event, whose target is the
EventSource, leaking the whole key via its ?apikey= URL. All of those are gone.
getAPIKeyPreview() itself is kept: it still backs an on-screen preview in the
auth modal.

Tests: new internal/httpapi/metrics_auth_test.go (401 without a key, 401 with a
wrong key, 200 with X-API-Key and with Bearer, 403 for a real agent token, all
five health aliases still open) — built on a controller returning a real
*config.Config so it cannot pass vacuously through the middleware's
not-a-config passthrough. internal/server/e2e_metrics_test.go flips from
asserting an unauthenticated 200 to 401-then-authenticated-200. New
frontend/tests/unit/api-key-not-logged.spec.ts asserts no console call
stringifies to the secret or its first 8 characters.

Follow-ups, deliberately not in this change: the admin key is persisted in
localStorage (a session-cookie flow is a larger UX change); the shared HTTP
logging middleware writes r.URL.RawQuery verbatim, so a ?apikey= credential on
any route lands in http.log; Activity.vue logs raw activity payloads.

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

cloudflare-workers-and-pages Bot commented Sep 22, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 29c470b
Status: ✅  Deploy successful!
Preview URL: https://6f386a33.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-protect-metrics-endpoint.mcpproxy-docs.pages.dev

View logs

it('never writes the key or its prefix to the console', async () => {
// The singleton runs initializeAPIKey() in its constructor at import time,
// so seed storage before importing.
localStorage.setItem(STORAGE_KEY, SECRET)
// api.getAPIKeyPreview() every time it opened the SSE stream, so a spec that
// only drives APIService would miss the leak that actually fires on app boot.
it('never leaks the key through the system store SSE connect path', async () => {
localStorage.setItem(STORAGE_KEY, SECRET)
@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

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/protect-metrics-endpoint

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-goX214M2.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 35815344798 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…7 review)

Second-lens review on PR #1344 found two SHOULD-FIX gaps:

- docs/features/observability.md: the Authentication section listed only
  X-API-Key and Authorization: Bearer as accepted /metrics credentials,
  omitting the ?apikey= query parameter that authenticateWithPrecedence
  also accepts (step 3, internal/httpapi/server.go). Documented it, with
  a caution that query-string credentials are the form most likely to
  leak into intermediary access logs.

- frontend/tests/unit/api-key-not-logged.spec.ts: the URL-parameter
  test's window.history.replaceState cleanup only ran after its
  assertions passed, so a failing assertion left ?apikey=<SECRET> on the
  jsdom URL for whatever spec ran next in the module. Moved the cleanup
  into afterEach so it always runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dumbris

Dumbris commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

Second-lens review — applied

Both SHOULD-FIX findings verified against the code and applied:

  • docs/features/observability.md: authenticateWithPrecedence (internal/httpapi/server.go, step 3) also accepts ?apikey=<key> for /metrics, matching the rest of the REST API — the Authentication section only listed the two header forms. Documented the query parameter and added a caution that it's the credential form most likely to leak into intermediary/proxy access logs.
  • frontend/tests/unit/api-key-not-logged.spec.ts: the URL-parameter test's window.history.replaceState cleanup only ran after its own assertions passed, so a failed assertion would leave ?apikey=<SECRET> on the jsdom URL for later specs in the module. Moved the cleanup into afterEach so it always runs.

No MUST-FIX findings on this round. Both changes are docs/test-only — no production behavior change, so no new failing test was needed. Verified: go build ./cmd/mcpproxy, full frontend unit suite (132 files / 1328 tests), and the internal/httpapi auth/metrics tests under -race — all green.

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.

3 participants