Repository navigation
fix(mcp): refuse unauthenticated HTTP on non-loopback hosts; optional bearer-token auth - #3599
Conversation
startHttpServer() constructed @claude-flow/mcp's MCPServer without ever setting requireToolAuthorization or installing a toolAuthorizer. Since ToolRegistry's authorization check only runs `if (this.authorizer)`, every registered MCP tool was callable unauthenticated whenever the HTTP server was reachable, and --host is a live CLI flag that can bind off loopback — the same shape as CVE-2026-81735 (CVSS 10/10, UI-TARS-desktop mcp-http-server). Refuse to start on a non-loopback host unless RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1 is explicitly set; loopback (127.0.0.1/::1/localhost) behavior is unchanged. Flagged HIGH severity and deferred by the 2026-09-30 dream cycle's security scan (issue #3562) as a candidate for tonight's security-DEEP run. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RLwLj72DK7t8XiHJYDGaf7
Research gist for tonight's security-DEEP dream cycle (MCP HTTP tool-authorization fail-open fix), committed per repo convention since gist-hosting tooling (gh CLI, MCP gist tool) is unavailable this session. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RLwLj72DK7t8XiHJYDGaf7
Records tonight's MCP HTTP tool-authorization fail-open fix (#3598/#3599, ACCEPT) and the verified GitHub fates for 09-21 through 09-30. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RLwLj72DK7t8XiHJYDGaf7
…nbuilt PR #3599's new e2e tests spawned bin/cli.js, which always exists as tracked source, but actually needs dist/src/index.js to run. This repo's CI "Test Suite" job never builds @claude-flow/cli's own dist (only @claude-flow/security and @claude-flow/cli-core get a targeted build step there, per .github/workflows/ci.yml) — a pre-existing gap, same class as the one documented for other packages in that same workflow file. The old CLI_BUILT check (fs.existsSync on bin/cli.js alone, the same pattern mcp-http-foreground-2984/mcp-http-protocol-tools-2990 use) reported "built" regardless, so every e2e test failed with ERR_MODULE_NOT_FOUND in CI. Check the real dependency (dist/src/index.js) and skip the e2e describe block cleanly via describe.skipIf when it's absent, instead of throwing. The 3 unit tests (which don't spawn the CLI) are unaffected and already demonstrated the fix discriminates baseline from candidate on their own. Verified locally: passes 6/6 when dist exists, skips the 3 e2e tests cleanly (3 passed, 3 skipped, 0 failed) when dist is removed to simulate CI's actual state. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RLwLj72DK7t8XiHJYDGaf7
|
CI failure on
Root cause: Not this PR's root cause to fix (the CI workflow's build-step gap is pre-existing, shared infra, out of scope for a scoped security patch), but the resulting red test is in code this PR added, so: fixed by checking the real dependency ( Verified locally both ways: 6/6 pass when Generated by Claude Code |
… gate The previous CI fix (skipping the e2e tests via describe.skipIf when dist/src/index.js is absent) still failed identically in CI: 3 tests with the same AssertionErrors as before the skip guard was added, meaning dist/src/index.js must be present or otherwise resolvable there for a different reason than locally reproducible — root cause not fully diagnosed, and not worth further CI-only guessing given both existing tests of this exact shape (mcp-http-foreground-2984.test.ts, mcp-http-protocol-tools-2990.test.ts) are already listed in scripts/ci-test-baseline.txt as known-failing there. That file's own header is explicit: "Never add entries to make a regression green" — so adding a third entry for this file was not an option. Instead: extracted the full startHttpServer() gate decision into pure, synchronous, exported functions (isLoopbackHost, plus the existing isUnauthenticatedHttpAllowed, composed into shouldRefuseUnauthenticatedHttp) and rewrote the test file to unit-test them directly — zero process spawn, zero port bind, zero build dependency, so it cannot hit this or any other CI-environment-specific failure mode. This fully pins down the same behavior contract (refuse non-loopback by default, allow with explicit opt-out, loopback unchanged) that the original end-to-end tests covered, and was manually verified end-to-end during development (see PR #3599 / issue #3598's evidence: baseline-fails/candidate-passes via git-stash, a live spawned server demonstrably serving /health unauthenticated pre-fix and refusing to start post-fix). Stash-isolated: 5/8 new tests fail against true baseline (the 3 that still "pass" are isUnauthenticatedHttpAllowed's own pre-existing unit tests, unaffected by this candidate), 8/8 pass on the candidate. No behavior change to startHttpServer() itself beyond the refactor — same inputs produce the same refuse/allow decision as the previous inline version. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RLwLj72DK7t8XiHJYDGaf7
|
Follow-up on the CI failure ( Rather than keep guessing at why
Re-running CI on Generated by Claude Code |
|
CI on This is not this PR's: it's a timing-based wall-clock speedup assertion in a package ( Generated by Claude Code |
|
CI is green on Updated the PR description to match the final implementation (pure unit tests against the exported gate functions, 8/8 passing, no process spawn) and to document the two CI round-trips for anyone reviewing the commit history. This PR is ready for human review per standing Dream Cycle policy (draft, no self-merge). I'll keep watching for review comments or any future CI regression. Generated by Claude Code |
Updates tonight's row to reflect the final 8/8 unit-test suite (not the original 6/6 e2e-spawn design, replaced after two CI failures) and discloses the CI round-trip rather than silently updating the number. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RLwLj72DK7t8XiHJYDGaf7
… bearer-token auth - startHttpServer refuses a non-loopback --host unless RUFLO_MCP_HTTP_TOKEN / --auth-token-file / --auth-token is set, or RUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1. The gate now runs before any import or bind. - New optional bearer-token auth wired into the HTTP transport: constant-time compare, bare 401, no token logging, applied before the body parser; only GET/HEAD /health stays public and reveals liveness only. Auth enabled with no tokens now fails closed (previously any Bearer value was accepted). - Wiring tests through MCPServerManager.start() (mutation-checked) and transport tests covering bypass attempts; README + mcp start help document the env vars. Loopback without a token stays unauthenticated by design (documented). Co-Authored-By: RuFlo <ruv@ruv.net>
Refs #3598.
What was wrong
ruflo mcp start -t httpstarted with no authentication.@claude-flow/mcp's HTTP transport enforces a bearer token only whenconfig.authis set, and the CLI never set it. On published@claude-flow/cli3.54.1, with the default--host localhost, a rawinitializefollowed bytools/call system_infosucceeds with no credentials, and a spoofedHostheader is accepted. With--host 0.0.0.0the same is reachable from the network. Browser CSRF and DNS rebinding were blocked only incidentally by the CORS code.What this PR does
0.0.0.0,::, a LAN IP, ...) unless the server is authenticated or the operator opts out withRUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1. The gate runs first instartHttpServer, before any import or bind.RUFLO_MCP_HTTP_TOKEN=<token>(preferred),--auth-token-file <path>, or--auth-token <token>(visible in argv). Precedence: flag, file, env.Authorization: Bearer <token>:/rpc,/mcp(POST and the SSE GET) and/info. A failure is one generic401with no detail, noWWW-Authenticatehints beyondBearer, and nothing about the token in the body or logs.crypto.timingSafeEqualover SHA-256 digests, so length does not leak). Auth runs after CORS and the rate limiter and before the body parser, so an unauthenticated client cannot make the server read a body. The body size cap is unchanged.GET/HEAD /healthstays public and returns only{"status":"ok"}when auth is on (no timestamp, no connection count). Look-alike paths still need the token.tokenslist used to accept anyBearer <anything>. It now accepts nothing.startingevent.--auth-tokenwith-t websocketis rejected, because the standalone websocket transport has no working token check.What is NOT closed
Hostallow-list). SetRUFLO_MCP_HTTP_TOKENwhenever the HTTP transport is used;mcp startnow prints a warning in that case.localhost,127.0.0.1,::1(127.0.0.2,[::1],LOCALHOST,0:0:0:0:0:0:0:1) fail closed: they need a token or the opt-out.-t websockettransport on a non-loopback host is refused without the opt-out, and a token cannot unlock it./wsupgrade on the HTTP transport still accepts a?token=query parameter in addition to the header (existing behaviour, not changed here).The earlier title/description of this PR called this "fail-open by default / CVSS 10". That overstated it: the loopback default was and remains unauthenticated, and the original change only covered non-loopback binds.
Behaviour matrix
--hostlocalhost,127.0.0.1,::10.0.0.0,::, LAN IP,127.0.0.2,[::1],LOCALHOSTRUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1Breaking change
Anyone binding off loopback, for example Docker with
--host 0.0.0.0, must now setRUFLO_MCP_HTTP_TOKEN(and send the header from clients) or setRUFLO_MCP_ALLOW_UNAUTHENTICATED_HTTP=1. Otherwisemcp startexits with an error. Loopback users are unaffected.Tests
cli/__tests__/mcp-http-nonloopback-auth-gate.test.ts(34): drives the realMCPServerManager.start()with@claude-flow/mcpmocked.0.0.0.0,::and a LAN IP are refused with the documented message and never reachcreateMCPServer; they start with the opt-out or a token (and the token reaches the transport config); loopback hosts start; token resolution/validation; the full decision matrix. Mutation check: deleting thethrowinstartHttpServerfails 5 of these tests (the previous version of this PR's tests stayed green).mcp/__tests__/http-transport-auth.test.ts(26): no header, wrong token, near-miss tokens, right token, malformed headers, unicode tokens,timingSafeEqualis used, no token in logs, body cap kept, auth before body parse,/healthbehaviour, CORS preflight unchanged, fail-closed with no tokens, and bypass attempts (method override headers and params, HEAD/OPTIONS,bearer/BEARER, duplicate and comma-joinedAuthorization, path variants, forgedHost/X-Forwarded-*, credentials in query or body).mcppackage 107/107; 7 related cli test files 501/501;tsc --noEmitfor cli shows the same 5 pre-existing errors as before (none in touched files). One existing test'smcp-server.jsmock needed the two new exports.MCPServerManager: token on0.0.0.0gives no header 401, wrong token 401, right token 200,/health200; loopback with no token gives 200 for all three (unchanged).Docs:
v3/@claude-flow/cli/README.md("HTTP transport security") andruflo mcp start --help. No version bumps or lockfile changes.Human review required; not self-merging.
🤖 Generated with RuFlo