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:
parent
6d79ad32fb
commit
0c4848c656
3 changed files with 31 additions and 2 deletions
|
|
@ -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
|
stack's network) so the `timescale_db` hostname resolves. Coolify manages the Traefik
|
||||||
labels + TLS from the domain you set.
|
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`
|
**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
|
— it builds the image, derives the read-only `DATABASE_URL` from the running stack, and
|
||||||
runs a standalone Traefik bridge. See the script header.
|
runs a standalone Traefik bridge. See the script header.
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
import psycopg2.pool
|
import psycopg2.pool
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
|
@ -143,7 +144,27 @@ def _guard(sql: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
# ── MCP server + tools ───────────────────────────────────────────────────────
|
# ── 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()
|
@mcp.tool()
|
||||||
|
|
|
||||||
|
|
@ -74,4 +74,4 @@ rm -f "$ENV_FILE"
|
||||||
sleep 5
|
sleep 5
|
||||||
echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}"
|
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 "== 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue