Skip to content

fix(serve): enable Host/Origin protection on the --http MCP server#671

Open
spartan8806 wants to merge 1 commit into
tirth8205:mainfrom
spartan8806:harden-http-origin
Open

fix(serve): enable Host/Origin protection on the --http MCP server#671
spartan8806 wants to merge 1 commit into
tirth8205:mainfrom
spartan8806:harden-http-origin

Conversation

@spartan8806

Copy link
Copy Markdown

Small hardening for the opt-in HTTP mode. serve --http starts a FastMCP streamable-http server on 127.0.0.1:5555, but FastMCP's host_origin_protection defaults to off — so the endpoint accepts requests from any Origin. A web page the user visits could reach the loopback port via DNS rebinding and drive the MCP tools (which read the codebase).

This passes host_origin_protection="auto" so FastMCP validates Host/Origin for the loopback-bound server. Verified locally: a foreign Origin now gets 403 Forbidden Origin, while non-browser clients (no Origin header) and same-origin requests keep working. Default stdio transport is unaffected.

Low impact — opt-in --http, read-only tools, requires rebinding — but a cheap defense-in-depth. Hope it's useful! 🙂

Checklist

  • Tests added for new functionality — test_main.py asserts the kwarg is passed
  • All tests pass: uv run pytest — 3 passed (serve-transport tests)
  • Linting passes: uv run ruff check — All checks passed
  • Type checking passes: uv run mypy — Success, no issues
  • Lines ≤ 100 characters
  • Docs updated — module docstring notes the origin protection

@tirth8205

Copy link
Copy Markdown
Owner

This currently crashes before the HTTP server starts. The locked FastMCP 3.2.4 run_http_async signature does not accept host_origin_protection; binding that keyword raises TypeError: unexpected keyword argument. The mock-only test cannot catch the production failure. Please use a supported middleware/server configuration or intentionally upgrade FastMCP, then test foreign Origin rejection plus same-Origin and no-Origin clients end to end.

`serve --http` starts a FastMCP streamable-http server on 127.0.0.1:5555. A
loopback bind is not by itself an access control for a browser: a page the user
visits can point a hostname it controls at 127.0.0.1 (DNS rebinding) and then
drive the MCP tools, which read the user's source tree.

Add a pure-ASGI `LoopbackOriginGuard`, passed through `run_http_async`'s
supported `middleware=[...]` parameter, so nothing outside the pinned FastMCP
3.2.4 API is required. It is pure ASGI rather than `BaseHTTPMiddleware` because
streamable-http holds long-lived streaming/SSE responses open, which
`BaseHTTPMiddleware` interferes with.

The guard rejects a `Host` that is not the loopback endpoint -- this is what
stops DNS rebinding, since the rebound request carries the attacker's hostname
-- and rejects a present `Origin` that is not the loopback endpoint. An absent
`Origin` is allowed, because non-browser MCP clients do not send one. The guard
engages only for a loopback bind; `--host 0.0.0.0` is an explicit decision to
expose the endpoint and passes through untouched.

Tests exercise the real FastMCP ASGI app (`mcp.http_app(middleware=...)`, which
is what `run_http_async` builds) rather than a mock:

  - foreign Origin over http and https      -> 403
  - same-origin and localhost Origin        -> not blocked
  - no Origin at all (ordinary MCP client)  -> not blocked
  - rebound Host, with and without the port -> 403
  - Origin on the wrong port / non-http     -> 403

A signature-contract test binds the kwargs the entry point passes against the
real `FastMCP.run_http_async` signature, so a keyword the pinned FastMCP does
not accept cannot pass review behind a mocked `mcp.run`.
@spartan8806

Copy link
Copy Markdown
Author

You're right, and thanks for catching it. I confirmed it against the locked FastMCP 3.2.4: run_http_async takes no **kwargs and has no host_origin_protection parameter — that setting doesn't exist anywhere in 3.2.4 — so mcp.run(...) forwarded it through and raised TypeError: TransportMixin.run_http_async() got an unexpected keyword argument 'host_origin_protection' before the server started. My test mocked mcp.run and asserted the kwargs, so it passed while the real path was broken, exactly as you described. That one's on me — the "verified locally" line in the description did not hold against the pinned version.

I've replaced it with a supported configuration on 3.2.4, with no dependency change.

What changed

  • New code_review_graph/http_origin_guard.py — a small pure-ASGI middleware passed via run_http_async's supported middleware=[...] parameter. Pure ASGI rather than BaseHTTPMiddleware because streamable-http holds long-lived streaming/SSE responses open, which BaseHTTPMiddleware interferes with.
  • It rejects requests whose Host isn't the loopback endpoint — that is what actually stops DNS rebinding, since the rebound request carries the attacker's hostname — and rejects a present Origin that isn't the loopback endpoint. An absent Origin is allowed, since non-browser MCP clients don't send one.
  • The guard only engages for a loopback bind. --host 0.0.0.0 is an explicit decision to expose the endpoint, where your own hostnames are legitimate, so it passes through untouched.

Tests

End-to-end against the real FastMCP ASGI app (mcp.http_app(middleware=...), which is exactly what run_http_async builds), not mocks:

Request Result
Foreign Origin (http and https) 403
Same-origin Origin, and http://localhost:5555 not blocked
No Origin at all (ordinary MCP client) not blocked
Rebound Host (evil.example, with and without port) 403
Origin on the wrong port / non-http scheme 403

There is also a signature-contract test that binds the kwargs the entry point passes against the real FastMCP.run_http_async signature. I verified it fails on the previous patch (TypeError: unexpected keyword argument 'host_origin_protection') and passes on this one, so a keyword the pinned FastMCP doesn't accept can't slip past a mock again.

Checks on the locked toolchain

  • ruff check code_review_graph/ — all checks passed
  • mypy code_review_graph/ --ignore-missing-imports --no-strict-optional — no issues
  • pytest tests/test_http_origin_guard.py tests/test_main.py — 48 passed

On the full suite I see 18 failures locally (Julia/HCL parser reconciliation, and the symlink / file-mode tests), but they reproduce identically with this change stashed, so they're pre-existing on my Windows environment rather than anything this PR touches.

Happy to narrow the scope or drop it entirely if you'd rather not carry the middleware.

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