fix: disable MCP DNS-rebinding host guard behind reverse proxy

The MCP SDK's transport-security DNS-rebinding protection only accepts a
localhost Host header by default and returns 421 behind Traefik (Host =
fleetmcp.*). It targets browser attacks on localhost-bound servers and does
not apply to a public, TLS-terminated, Bearer-authenticated service. Off by
default now; re-enableable via MCP_DNS_REBINDING_PROTECTION=1 + MCP_ALLOWED_HOSTS.
Also: deploy.sh health echo uses python (slim image has no curl).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-17 00:00:40 +03:00
parent 6d79ad32fb
commit 0c4848c656
3 changed files with 31 additions and 2 deletions

View file

@ -69,6 +69,14 @@ Then **connect the app to the network that can reach `timescale_db`** (the track
stack's network) so the `timescale_db` hostname resolves. Coolify manages the Traefik
labels + TLS from the domain you set.
Optional env (sensible defaults):
- `MCP_MAX_ROWS` (default `10000`) — hard ceiling on rows a query may return.
- `MCP_DNS_REBINDING_PROTECTION` (default `0`/off) — the MCP SDK's localhost Host-header
guard. It returns `421` behind a reverse proxy, so it's off by default (the service is
TLS-terminated + Bearer-authed). Set `1` to enforce, with `MCP_ALLOWED_HOSTS`.
- `MCP_ALLOWED_HOSTS` — comma-separated allowlist used only when the guard is on
(default: the two `fleetmcp.*` domains + localhost).
**1b. Manual host deploy (fallback):** check this repo out on the host and run `deploy.sh`
— it builds the image, derives the read-only `DATABASE_URL` from the running stack, and
runs a standalone Traefik bridge. See the script header.

View file

@ -36,6 +36,7 @@ import psycopg2
import psycopg2.extras
import psycopg2.pool
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
@ -143,7 +144,27 @@ def _guard(sql: str) -> str:
# ── MCP server + tools ───────────────────────────────────────────────────────
mcp = FastMCP("fireside-analytics", stateless_http=True)
# The MCP SDK ships DNS-rebinding protection that, by default, only accepts a
# localhost Host header and returns 421 for anything else — which breaks this
# service behind Traefik (Host = fleetmcp.*). That protection targets browser
# attacks on localhost-bound servers; it does not apply to a public, TLS-terminated,
# Bearer-authenticated service. So it is OFF by default here, and re-enableable via
# MCP_DNS_REBINDING_PROTECTION=1 with an explicit MCP_ALLOWED_HOSTS allowlist.
_DNS_PROT = os.getenv("MCP_DNS_REBINDING_PROTECTION", "0") == "1"
_ALLOWED_HOSTS = [
h.strip()
for h in os.getenv(
"MCP_ALLOWED_HOSTS",
"fleetmcp.fivetitude.com,fleetmcp.rahamafresh.com,localhost,127.0.0.1",
).split(",")
if h.strip()
]
_transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=_DNS_PROT,
allowed_hosts=_ALLOWED_HOSTS,
allowed_origins=[f"https://{h}" for h in _ALLOWED_HOSTS],
)
mcp = FastMCP("fireside-analytics", stateless_http=True, transport_security=_transport_security)
@mcp.tool()

View file

@ -74,4 +74,4 @@ rm -f "$ENV_FILE"
sleep 5
echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}"
echo "== DB role (expect analytics_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:<pw>@#"'
echo "== health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/healthz" 2>&1 | head
echo "== health =="; docker exec "$NAME" python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:${PORT}/healthz').read().decode())" 2>&1 | head