Skip to content

refactor(httpapi): type the controller seams and fail closed without a config - #1346

Merged
Dumbris merged 2 commits into
mainfrom
fix/typed-controller-seams
Sep 24, 2026
Merged

Dumbris merged 2 commits into
mainfrom
fix/typed-controller-seams

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 22, 2026

Copy link
Copy Markdown
Member

What

Types two interface{} seams on httpapi.ServerController and makes the auth middleware fail closed when it has no configuration.

ARC-04 — the management service travelled as interface{}. It left internal/runtime untyped, crossed ServerController, and was re-derived in internal/httpapi by 12 ad-hoc anonymous interface assertions. Five of those were unchecked (mgmtSvc.(interface{...}).Method(...)), so a method-set drift panicked into chi's Recoverer and surfaced as an opaque 500 with no log line naming the cause; the other seven degraded to ok=false and a 500. management.Service already describes exactly what those handlers call, and internal/httpapi already imports internal/management, so the seam is now typed to it end to end and all 12 assertions are gone. The three equivalents on the MCP surface (internal/server/mcp.go) go with them.

SEC-02 — two fail-open branches in apiKeyAuthMiddleware. Typing GetCurrentConfig() to *config.Config makes the "not the expected type" branch unrepresentable; the remaining nil path now returns 503 instead of forwarding the request. To be precise about severity: in production Runtime.GetCurrentConfig() wrapped a *config.Config and runtime.New rejects a nil config, so this was not an exploitable bypass there — but it was a live one for any other implementor of the interface, and 37 existing test requests were authenticating by not authenticating.

Why it is one change

Both defects are the same missing type. The named interface the seam needed already existed; nothing new was designed.

Notable

  • handleGetGlobalTools keeps its deliberate graceful fallback to controller.GetServerTools; the nil check stays on the interface value.
  • Three security tests (Spec 099 FR-018a's disclosure floor, and auth.AuthorizeServerOp's unrestricted-on-absent-AuthContext default) reached their handlers through the deleted branch. They are rewritten, not deleted: they now drive the handler and the subtree gate directly with a no-AuthContext request and keep the same assertions. index_search_scoped moved for a different reason — the middleware would replace the AuthContext those tests inject and turn every scoped fixture into an admin one.
  • No REST contract, OAS or frontend change. The only new status code is the 503.

How verified

TDD: both tests were written and run red first (503-and-handler-not-reached failed 200/true; the compile-time contract failed to build).

  • go test -race on internal/httpapi, internal/runtime, internal/management, and internal/server with the unit-tests.yml skip regex
  • the server-edition race matrix under -tags server (which is where index_search_scoped_test.go lives — invisible to a bare run)
  • both golangci-lint passes, bare and --build-tags server; the 16/19 findings are pre-existing and untouched
  • live instance: all 12 converted call sites exercised over REST and MCP against a real mcpproxy serveservers, doctor, {id}/tools, enable/disable, restart, restart_all, enable_all, disable_all, logout, global tools, and upstream_servers restart/enable/disable over JSON-RPC. Unauthenticated request still 401s; zero panics in the log.

Cross-model review: codex gpt-5.6-sol, one round, no production findings. It raised one P3 — the rewritten tests no longer traverse the full chi route. I verified it and did not change the code: at least five other tests (scope_reveal_test.go:341, contracts_test.go:429, search_tools_test.go:48, security_test.go:57, tenant_allowlist_walk_test.go:129) already drive /api/v1/index/search through the real router, and re-plumbing the rewritten tests would destroy the very property they pin.

🤖 Generated with Claude Code

…a config

The management service travelled from internal/runtime through
httpapi.ServerController as interface{}, and twelve handler sites in
internal/httpapi re-derived its method set with ad-hoc anonymous interface
assertions. Five of those were unchecked, so a method-set drift became a
panic recovered by chi into an opaque 500 with no log line naming the cause;
the other seven degraded to a runtime ok=false and a 500.

management.Service already describes exactly what those handlers call and
internal/httpapi already imports internal/management, so the seam is typed
to it end to end (runtime field + setter + getter, internal/server.Server,
httpapi.ServerController) and all twelve assertions are deleted. The three
equivalents on the MCP surface in internal/server/mcp.go go with them.

apiKeyAuthMiddleware had two "allow through (testing scenario)" branches on
GetCurrentConfig(). Typing the getter to *config.Config makes the
non-*config.Config branch unrepresentable, and the remaining nil path now
fails CLOSED with 503: no configuration means there is nothing to
authenticate against, so the request cannot be authenticated. In production
Runtime.GetCurrentConfig() wrapped a *config.Config and runtime.New rejects
a nil config, so this was not an exploitable bypass there — but it was a
live one for any other implementor, and 37 test requests authenticated by
not authenticating.

Three security tests (Spec 099 FR-018a's disclosure floor, and
auth.AuthorizeServerOp's unrestricted-on-absent-AuthContext default) reached
their handlers through the deleted branch. They are rewritten to drive the
handler and the subtree gate directly with a no-AuthContext request, keeping
the same assertions, rather than deleted with the branch. index_search_scoped
does the same: the middleware would otherwise replace the AuthContext those
tests inject and turn every scoped fixture into an admin one.

Verified on a live instance: all twelve converted call sites exercised over
REST and MCP, auth still required, no panics.

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: eba0071
Status: ✅  Deploy successful!
Preview URL: https://fdd96f12.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-typed-controller-seams.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/typed-controller-seams

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

Note: Artifacts expire in 14 days.

@codecov-commenter

codecov-commenter commented Sep 22, 2026

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 63.82979% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/httpapi/server.go 69.69% 4 Missing and 6 partials ⚠️
internal/runtime/runtime.go 0.00% 3 Missing ⚠️
internal/server/mcp.go 0.00% 3 Missing ⚠️
internal/server/update_failure.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

… seams

- remove dead X-API-Key Set in TestAddFromRegistry_NilConfigIsAnError
  (the first Set was immediately overwritten and asserted nothing)
- TestAPIKeyAuth_NilConfigFailsClosed: assert the 503 body text, not
  just the status code, so an unrelated earlier 503 can't pass the
  same assertions for the wrong reason
- GetCurrentConfig(): guard s.runtime == nil like its sibling
  GetManagementService(); unreachable in production but latent for a
  hand-built *Server in tests. Regression test added
  (get_current_config_test.go) reproducing the prior nil-pointer panic.

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

Dumbris commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

Applied a second-lens review pass to this PR (no MUST FIX findings; three SHOULD FIX findings applied, one left for a follow-up):

  • internal/httpapi/registry_add_test.go: removed the dead X-API-Key header set in TestAddFromRegistry_NilConfigIsAnError — the line was immediately overwritten by the next Set and tested nothing.
  • internal/httpapi/auth_middleware_test.go: TestAPIKeyAuth_NilConfigFailsClosed now also asserts the 503 body contains "cannot authenticate", not just the status code, so an unrelated earlier 503 couldn't pass the same assertions for the wrong reason.
  • internal/server/server.go: GetCurrentConfig() now guards s.runtime == nil like its sibling GetManagementService() (pre-existing asymmetry, unreachable in production since runtime.New rejects a nil config, but latent for a hand-built *Server in tests). Added a failing-first regression test (internal/server/get_current_config_test.go) that reproduced the nil-pointer panic before the fix.

Left for a follow-up doc pass rather than fixed here: the stale "kept as interface{} to avoid circular imports" / "middleware's test/no-config passthrough" comments at internal/httpapi/server.go:740-742, internal/httpapi/preflight.go:174-183, internal/runtime/event_bus.go:140-144, and internal/serveredition/registry.go:39. They're genuinely out of date (this PR both types the seam and removes the no-config passthrough it describes), but correcting them touches four files' worth of subtle auth/typing rationale for a comment-only change, and getting the wording wrong is higher-risk than leaving a // TODO for a dedicated pass — no behavior depends on them.

Verified: go build, both golangci-lint passes (bare + --build-tags server) clean on the touched files, -race tests green on internal/httpapi and internal/server.

@Dumbris
Dumbris merged commit f02a531 into main Sep 24, 2026
42 checks passed
Dumbris added a commit that referenced this pull request Sep 24, 2026
#1359)

* fix(httpapi): restore the build after the typed controller seam landed

#1346 changed ServerController.GetCurrentConfig to return *config.Config,
while #1350 (merged just before it) added currentAdminAPIKey with an
interface type assertion on that value and a cors_test controller returning
any. Each PR was green alone; together main does not compile.

* docs(httpapi): fix the stale nil-config note on currentAdminAPIKey
Dumbris added a commit that referenced this pull request Sep 24, 2026
/metrics now requires the admin API key. This test only passed before
because its mock controller handed the auth middleware no config, which
the middleware used to forward unauthenticated; #1346 made that fail
closed.
Dumbris added a commit that referenced this pull request Sep 24, 2026
…PI key (SEC-07) (#1344)

* fix(security): require admin auth for /metrics and stop logging the API 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>

* docs+test: document ?apikey= for /metrics, harden test cleanup (SEC-07 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>

* test(httpapi): authenticate the readiness test's /metrics probe (SEC-07)

/metrics now requires the admin API key. This test only passed before
because its mock controller handed the auth middleware no config, which
the middleware used to forward unauthenticated; #1346 made that fail
closed.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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