Skip to content

Unauthenticated /healthz leaks full backend config; /uc/select allows unauthenticated model-routing mutation #19

Description

@tg12

Summary

/api/models is an unauthenticated endpoint that enumerates every configured model slug, display name, and provider — including which model is currently active — to any caller that can reach the shim's loopback port. The route has no token check of any kind.

Evidence

codex_shim/server.py, ShimServer.app():

app.router.add_get("/api/models", self.api_models)

ShimServer.api_models() (no auth guard):

async def api_models(self, _request: web.Request) -> web.Response:
    current = _current_managed_model()
    data: list[dict[str, Any]] = []
    ...
    return web.json_response(data)

Compare to switch_model, which does check the picker token:

async def switch_model(self, request: web.Request) -> web.Response:
    if not self._valid_picker_token(request):
        return web.json_response({"error": "forbidden"}, status=403)

The picker HTML at /picker embeds the token only for the switch call — the model list fetch uses bare fetch('/api/models') with no Authorization header.

Why this matters

The endpoint reveals the full set of configured upstream providers (Anthropic, OpenAI, Ollama, Cursor, ChatGPT passthrough), their display names, and which model is active. An attacker with DNS rebinding access can silently inventory the target's AI toolchain. The same information can be useful to social-engineering attacks or to a malicious browser extension running on the user's machine.

Attack or failure scenario

  1. Attacker hosts evil.com resolving to 127.0.0.1 via DNS rebinding.
  2. Victim visits evil.com; their browser sends GET http://127.0.0.1:8765/api/models — the Host header is evil.com, which the host_guard_middleware rejects for normal loopback variants.
  3. However: if the victim has set CODEX_SHIM_ALLOWED_HOSTS=shim.local and the attacker can predict or guess that value, or if the user has --host 0.0.0.0 (which disables the wildcard guard), /api/models is fully open. More practically, a malicious browser extension or local script can hit 127.0.0.1:8765/api/models directly, bypassing the Host header guard entirely, because extensions are not subject to Host-header origin restrictions.

Root cause

The /api/models route was designed as a companion to the picker UI. The picker page itself requires the browser to already hold the picker token (embedded in the page HTML), but the data endpoint that populates the picker was not guarded with the same token check.

Recommended fix

Apply the same _valid_picker_token check to api_models:

async def api_models(self, request: web.Request) -> web.Response:
    if not self._valid_picker_token(request):
        return web.json_response({"error": "forbidden"}, status=403)
    ...

This is consistent with the existing token design and requires no architectural change.

Acceptance criteria

  • GET /api/models without the X-Codex-Shim-Picker-Token header returns HTTP 403.
  • GET /api/models with the correct token continues to return model list.
  • Existing test test_api_models_lists_configured_models_with_active_flag is updated to pass the picker token, and a new test verifies the 403 path.

Suggested labels

security, bug

Priority

P2

Severity

Medium — information disclosure only; the host-guard provides a meaningful partial barrier, but it is not an absolute defence against local extensions or misconfigured bind hosts.

Confidence

Confirmed — the absence of an auth check on api_models is unambiguous in the source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions