Skip to content

refactor: HTTP transport as local-only with fail-closed bind guard #118

Description

@salimovartem

Part of #121.

Goal

Make the MCP server's HTTP transport honestly local-only in both documentation and enforcement: rewrite the README so it stops promising "hosted marketplace deployments", make the bind address configurable via COREZOID_BIND_ADDR, and add a fail-closed guard that refuses to bind to a non-loopback address unless the operator explicitly opts in to running without built-in authentication.

Context

README.md:170 currently says:

COREZOID_HTTP_PORT — Activate the Streamable HTTP transport on this port (e.g. 8080). When set the server listens for MCP over HTTP instead of stdio — intended for hosted marketplace deployments. Credentials must be pre-configured in ~/.corezoid/config.json; the browser OAuth login flow is not available in HTTP mode.

The implementation does not match that promise:

  • plugins/corezoid/mcp-server/main.go:258 hardcodes addr := "127.0.0.1:" + port — the server can only bind to loopback.
  • plugins/corezoid/mcp-server/mcp_http.go:184 (httpMCPEndpoint) has no Bearer-token verification, no auth middleware. The Authorization header is announced in the CORS allowlist at mcp_http.go:175 but read nowhere.
  • OAuth credentials are loaded once at startup into a process-global apiToken (main.go:94-107, mcp_http.go:124-131). All HTTP requests share the same token — there is no per-request or per-tenant resolution.
  • The single "session" concept (Mcp-Session-Id header) is not cryptographic and carries no credentials.

For a local single-user setup this is fine and even safe — loopback is not exposed to the network, and the operator explicitly configures their own credentials. It is however not a hosted deployment model, and the current README wording invites someone to put this behind a reverse proxy and expose it, which would hand out full Corezoid API access with no authentication.

An external audit flagged this as a P0 defect. This issue closes the gap by making the security model match the documentation, and installs a runtime guard so the mismatch cannot be re-introduced by env-var alone.

A full hosted mode (per-tenant credentials, Bearer middleware with WWW-Authenticate, OAuth protected-resource metadata, rate limiting, audit log) is deliberately out of scope and tracked separately in plugins/corezoid/mcp-server/HOSTED_TODO.md (created by this issue).

Scope

In scope:

  • Introduce env var COREZOID_BIND_ADDR in the HTTP mode startup path (main.go:255-258 region). Default: 127.0.0.1. Accepted values without opt-in: 127.0.0.1, ::1, localhost.

  • Fail-closed guard: if COREZOID_BIND_ADDR resolves to anything else, refuse to start unless COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth is also set. Log a fatal error explaining the risk and exit non-zero. Match on lowercased hostname; do not attempt DNS resolution.

  • Rewrite the COREZOID_HTTP_PORT row in the "Environment variables" table in README.md:170 (and any adjacent HTTP-mode prose). New wording template:

    Activate the Streamable HTTP transport on this port. Bound to loopback (127.0.0.1) by default; override with COREZOID_BIND_ADDR. The endpoint has no built-in authentication — credentials are loaded once from ~/.corezoid/config.json and shared across all requests. Intended for local coding-agent integrations only. For hosted deployment, place behind a reverse proxy (Nginx / Caddy / Cloudflare Access) that terminates TLS, enforces authentication, and applies rate limiting — the MCP server does not replace those components.

  • Add a new COREZOID_BIND_ADDR row to the same env-var table with the same warning.

  • Create plugins/corezoid/mcp-server/HOSTED_TODO.md (short, ~40 lines) describing what a real hosted mode requires: fail-closed Bearer middleware with proper WWW-Authenticate challenge, per-tenant credential resolution, OAuth 2.0 Protected Resource Metadata endpoint, rate limiting, audit log, TLS. Explicitly mark it as a separate future initiative — this file is a placeholder, not a task list to execute now.

  • Tests:

    • TestHTTPBindGuard_Loopback — starting with COREZOID_HTTP_PORT=<free> and no COREZOID_BIND_ADDR binds to 127.0.0.1 (already the current behaviour, guard test).
    • TestHTTPBindGuard_ExplicitLoopbackCOREZOID_BIND_ADDR=127.0.0.1 and COREZOID_BIND_ADDR=::1 and COREZOID_BIND_ADDR=localhost all succeed without opt-in.
    • TestHTTPBindGuard_NonLoopbackRefusesCOREZOID_BIND_ADDR=0.0.0.0 without opt-in causes the startup function to return an error (or, if the function calls log.Fatal, refactor into a return + wire-up so it is testable). Message must mention the opt-in env var and the phrase "no built-in authentication".
    • TestHTTPBindGuard_NonLoopbackWithOptInCOREZOID_BIND_ADDR=0.0.0.0 with COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth succeeds.
  • Make the HTTP startup function return an error rather than calling log.Fatal directly, if it does not already. Callers in main.go translate to log.Fatal at the outermost layer as needed.

Out of scope:

  • Adding any actual authentication to the HTTP endpoint (that is what HOSTED_TODO.md is for).
  • Per-request tenant resolution or per-user credentials.
  • Rate limiting, audit log, Bearer middleware.
  • Reading and validating the Authorization header. Remove it from the CORS allowlist only if reviewers agree; safer to leave it announced but continue to ignore its value (documented in HOSTED_TODO).
  • Any change to stdio transport.

Files / areas to touch

  • plugins/corezoid/mcp-server/main.go — read COREZOID_BIND_ADDR around line 255; run guard before calling into mcp_http.go.
  • plugins/corezoid/mcp-server/mcp_http.go — accept the address as a parameter; refactor Fatal to error return if needed for testability.
  • plugins/corezoid/mcp-server/mcp_http_test.go — new bind-guard tests.
  • plugins/corezoid/mcp-server/HOSTED_TODO.md — new file.
  • README.md — rewrite the COREZOID_HTTP_PORT row and add COREZOID_BIND_ADDR row in the env-var table (around line 170).

Acceptance criteria

  • README.md:170 no longer contains the phrase "hosted marketplace deployments" or any language implying HTTP mode is a public-facing / multi-user transport.
  • The env-var table lists COREZOID_BIND_ADDR with default 127.0.0.1 and the auth caveat.
  • With COREZOID_HTTP_PORT=<port> set and no other overrides, the server binds to 127.0.0.1:<port> (unchanged behaviour).
  • With COREZOID_BIND_ADDR=0.0.0.0 set and no opt-in env, the server refuses to start with a non-zero exit code and a message mentioning the opt-in and "no built-in authentication".
  • With COREZOID_BIND_ADDR=0.0.0.0 COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth set, the server starts and binds to 0.0.0.0:<port>.
  • plugins/corezoid/mcp-server/HOSTED_TODO.md exists and describes the roadmap for real hosted mode.
  • All new tests pass under go test -race ./....
  • All existing tests continue to pass.
  • Markdown link check in CI (.github/workflows/ci.yml) still passes after README edits.

Verification

cd plugins/corezoid/mcp-server
go build ./...
go vet ./...
go test -race -run TestHTTPBindGuard ./...
go test -race ./...

# Manual smoke tests (in three separate shells):
# 1. Default loopback:
COREZOID_HTTP_PORT=18080 go run . mcp-server &
curl -sS http://127.0.0.1:18080/mcp -X POST -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' >/dev/null
# → returns JSON-RPC response (existing behaviour)

# 2. Non-loopback refuses:
COREZOID_HTTP_PORT=18080 COREZOID_BIND_ADDR=0.0.0.0 go run . mcp-server
# → exits non-zero with the fail-closed message

# 3. Non-loopback with explicit opt-in:
COREZOID_HTTP_PORT=18080 COREZOID_BIND_ADDR=0.0.0.0 \
  COREZOID_ALLOW_UNAUTHENTICATED_REMOTE=yes-i-know-there-is-no-auth \
  go run . mcp-server
# → binds and serves on 0.0.0.0:18080

# Markdown link check:
cd ../../..
# Whatever tool the CI uses (typically markdown-link-check or lychee)

Dependencies

  • Blocked by: None.
  • Blocks: None.
  • Related: None.

Notes

  • The opt-in phrase yes-i-know-there-is-no-auth is deliberately long and self-describing so it cannot be set by accident, cargo-culted from a tutorial, or invented by a package script without the operator reading it. Do not shorten it, do not accept aliases.
  • IPv6 loopback (::1) must be treated as safe alongside 127.0.0.1.
  • Do NOT bind to 0.0.0.0 in tests — allocate a free port with net.Listen("tcp", "127.0.0.1:0") and pass the resolved port to the server. Bind-guard tests should exercise the guard function directly (return-error contract), not attempt an actual 0.0.0.0 bind.
  • Do NOT attempt DNS resolution on COREZOID_BIND_ADDR. Compare the raw string against the allowlist. Operators who want to bind to a specific interface by IP can pass the IP directly.
  • If any published quick-start or install-script mentions COREZOID_HTTP_PORT for a "public server" scenario, patch or remove it as part of this PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions