Skip to content

Commit 826d12c

Browse files
vishal-balaclaude
andauthored
feat(mcp): validate Host/Origin headers on MCP HTTP transports (#643)
## Motivation RedisVL can expose a configured Redis index to MCP clients over HTTP, through the `streamable-http` and `sse` transports. Until now, once such a server was listening it would answer any HTTP request that reached its port, without confirming that the request was actually addressed to it. Validating that requests are intended for this server is a standard piece of transport hardening, and one worth having on by default rather than left to each deployment. This change adds that check: the HTTP transports now confirm, on every request, that it is genuinely addressed to this server before any tool runs. ## What changed The server validates the `Host` and `Origin` headers of each HTTP request against allowlists and rejects anything that doesn't match, before the request reaches the search or upsert tools. The protection is on by default and needs no configuration for the common cases — the allowlist is derived automatically from the address the server binds to, so loopback and same-machine clients keep working exactly as before. Requests that carry no `Origin` (typical of non-browser MCP clients) are unaffected; only cross-site origins are scrutinized. For deployments where the client-visible host legitimately differs from the bind address — behind a reverse proxy, or bound to `0.0.0.0` and reached by a public hostname — the allowlist can be extended, or the check relaxed, through a new `server.transport_security` block in the MCP config and matching `REDISVL_MCP_*` environment variables. This mirrors the existing authentication configuration, so it should feel familiar. Authentication and this check are complementary and independent: auth decides *who* may call, while this decides *which authority* a request may claim. The `stdio` transport has no network surface and is untouched. ## Implementation notes - A new `redisvl/mcp/transport_security.py` module holds a small ASGI middleware plus the config resolution, with FastMCP/Starlette imports deferred so the module stays importable without the optional `mcp` extra. - The guard is wired in through `RedisVLMCPServer.run_async`, so it applies to both the CLI and any programmatic embedder, and runs outermost — ahead of auth and the tool handlers. - Defaults are fail-closed: the guard is enabled when unconfigured, and a `0.0.0.0` bind trusts only loopback until the operator names its public hosts. ## Tests & docs Adds unit coverage for the middleware (host/origin matching, port and IPv6 handling, case-insensitivity, enable/disable) and for its config resolution, plus integration tests over real HTTP confirming that a legitimate local client still succeeds while spoofed hosts and cross-site origins are rejected. The MCP how-to and authentication guides gain a "Transport Security" section covering the defaults and the configuration knobs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes request handling on all HTTP MCP traffic (fail-closed by default); misconfigured proxies or remote `0.0.0.0` deployments without `allowed_hosts` can break legitimate clients until allowlists are set. > > **Overview** > Adds **default-on Host/Origin validation** on `streamable-http` and `sse` MCP transports to mitigate DNS rebinding and untrusted cross-site browser calls, before JWT auth or tool handlers run. > > A new `redisvl/mcp/transport_security` module provides ASGI `HostOriginValidationMiddleware`, bind-derived default hosts (loopback for `127.0.0.1` / `0.0.0.0`), and config resolution (YAML `server.transport_security` plus `REDISVL_MCP_*` env vars, env wins). `RedisVLMCPServer.run_async` prepends this middleware outermost for HTTP transports; `stdio` is unchanged. > > **Operator knobs:** extra `allowed_hosts` / `allowed_origins`, `allow_any_origin`, or `enabled: false` for reverse-proxy setups. Docs cover transport security separately from JWT auth and note that `0.0.0.0` binds need explicit client-visible hosts in the allowlist. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit eab122d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d026675 commit 826d12c

10 files changed

Lines changed: 830 additions & 2 deletions

File tree

docs/user_guide/how_to_guides/mcp.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,29 @@ uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport sse --hos
5757
Streamable HTTP and SSE endpoints are **unauthenticated by default**. Binding to a non-loopback host without auth fails closed unless you pass `--allow-unauthenticated`; binding to loopback without auth only warns. For real deployments, enable JWT authentication (see {doc}`mcp_authentication`) rather than using `--allow-unauthenticated`. When not using `--read-only`, the `upsert-records` tool is also exposed to any client that can reach the server.
5858
```
5959

60+
### Transport Security (Host / Origin Validation)
61+
62+
On the HTTP transports the server validates the request `Host` and `Origin` headers against allowlists before any tool runs. This is **on by default** and defends against [DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding): without it, a malicious web page could rebind its own hostname to `127.0.0.1` and use the victim's browser to reach a local MCP server, even one bound to loopback.
63+
64+
- **Host:** the allowlist is derived automatically from the bind address. Loopback binds accept `localhost`, `127.0.0.1`, and `[::1]` (with or without the port), so local MCP clients work with no configuration.
65+
- **Origin:** requests with no `Origin` header (typical of non-browser MCP clients) always pass. A request that carries a cross-site `Origin` is rejected unless that origin is explicitly allowlisted.
66+
67+
You only need to configure this for deployments where the client-visible `Host` differs from the bind address (a reverse proxy, or a `--host 0.0.0.0` bind reached via a public hostname). Use the `REDISVL_MCP_ALLOWED_HOSTS` / `REDISVL_MCP_ALLOWED_ORIGINS` env vars, or a `server.transport_security` block in the config:
68+
69+
```yaml
70+
server:
71+
redis_url: ${REDIS_URL}
72+
transport_security:
73+
allowed_hosts: [mcp.example.com] # extra Host values to accept
74+
allowed_origins: [https://app.example.com] # browser origins to accept
75+
# allow_any_origin: true # trust upstream Origin validation
76+
# enabled: false # disable when a trusted proxy validates
77+
```
78+
79+
```{note}
80+
Behind a reverse proxy or gateway that already validates `Host`/`Origin`, set `server.transport_security.enabled: false` (or `allow_any_origin: true`) so the proxy's rewritten headers are not rejected.
81+
```
82+
6083
Run it in read-only mode to expose search without upsert:
6184

6285
```bash
@@ -83,6 +106,10 @@ You can also control boot settings through environment variables:
83106
| `REDISVL_MCP_READ_ONLY` | Disable `upsert-records` when set to `true` |
84107
| `REDISVL_MCP_TOOL_SEARCH_DESCRIPTION` | Set the base search tool description text; RedisVL still appends schema-derived typed filter, `exists`, and `return_fields` hints |
85108
| `REDISVL_MCP_TOOL_UPSERT_DESCRIPTION` | Override the upsert tool description |
109+
| `REDISVL_MCP_ALLOWED_HOSTS` | Comma-separated extra `Host` header values to accept on HTTP transports (in addition to the bind-derived defaults) |
110+
| `REDISVL_MCP_ALLOWED_ORIGINS` | Comma-separated browser `Origin` values to accept on HTTP transports |
111+
| `REDISVL_MCP_ALLOW_ANY_ORIGIN` | Accept any `Origin` (for trusted-proxy setups) when set to `true` |
112+
| `REDISVL_MCP_TRANSPORT_SECURITY_ENABLED` | Set to `false` to disable Host/Origin validation (e.g. behind a validating proxy) |
86113

87114
## Connect a Remote MCP Client
88115

@@ -91,7 +118,7 @@ When using Streamable HTTP or SSE transport, point your MCP client at the server
91118
- **Streamable HTTP**: `http://<host>:<port>/mcp`
92119
- **SSE**: `http://<host>:<port>/sse`
93120

94-
> **Note:** `<host>` here is the bind address the server was started with. The default `127.0.0.1` only accepts connections from the same machine. To allow connections from other machines, start the server with `--host 0.0.0.0` and use the machine's actual IP or hostname in the client URL.
121+
> **Note:** `<host>` here is the bind address the server was started with. The default `127.0.0.1` only accepts connections from the same machine. To allow connections from other machines, start the server with `--host 0.0.0.0` and use the machine's actual IP or hostname in the client URL. Because a `0.0.0.0` bind has no single canonical `Host`, add the client-visible hostname(s) to `REDISVL_MCP_ALLOWED_HOSTS` (or `server.transport_security.allowed_hosts`), or Host validation will reject those requests. See [Transport Security](#transport-security-host-origin-validation).
95122
96123
For example, to configure a remote MCP client to connect to a Streamable HTTP server running on `192.168.1.10:8000`:
97124

docs/user_guide/how_to_guides/mcp_authentication.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ The `stdio` transport is a local subprocess with no network surface and is never
1818
authenticated.
1919
```
2020

21+
```{important}
22+
Authentication is a separate concern from **transport security** (Host/Origin
23+
validation), which is always on for the HTTP transports and defends against DNS
24+
rebinding independently of auth. See
25+
[Transport Security](mcp.md#transport-security-host-origin-validation). Both
26+
layers apply together: auth decides *who* may call; the Host/Origin guard
27+
rejects requests whose claimed authority is not allowlisted.
28+
```
29+
2130
## What RedisVL Enforces
2231

2332
RedisVL validates a bearer **JWT** that an existing identity provider (IdP)
@@ -199,6 +208,11 @@ Use RedisVL's JWT validation for authentication and coarse read/write
199208
authorization. Layer a gateway on top when you need per-tenant Redis ACL
200209
enforcement.
201210

211+
When such a gateway or reverse proxy terminates the connection and forwards a
212+
rewritten `Host` header, set `server.transport_security.enabled: false` (or
213+
declare the public host via `server.transport_security.allowed_hosts`) so the
214+
proxy's rewritten `Host` is not rejected by the Host/Origin guard.
215+
202216
## See Also
203217

204218
- {doc}`mcp`: run and configure the RedisVL MCP server.

redisvl/mcp/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from redisvl.mcp.config import MCPConfig, load_mcp_config
1+
from redisvl.mcp.config import MCPConfig, MCPTransportSecurityConfig, load_mcp_config
22
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception
33
from redisvl.mcp.server import RedisVLMCPServer
44
from redisvl.mcp.settings import MCPSettings
@@ -7,6 +7,7 @@
77
"MCPConfig",
88
"MCPErrorCode",
99
"MCPSettings",
10+
"MCPTransportSecurityConfig",
1011
"RedisVLMCPError",
1112
"RedisVLMCPServer",
1213
"load_mcp_config",

redisvl/mcp/config.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,41 @@ def _validate_jwt(self) -> "MCPAuthConfig":
165165
return self
166166

167167

168+
class MCPTransportSecurityConfig(BaseModel):
169+
"""Host/Origin validation for the MCP server's HTTP transports.
170+
171+
Only serves requests that target a trusted authority: the ``Host`` header
172+
must be allowlisted and any cross-site ``Origin`` must be too. This blocks
173+
requests that reach the server claiming an authority it does not own --
174+
DNS-rebinding being the primary motivating case, alongside cross-origin
175+
browser requests and misrouted/proxied traffic.
176+
177+
Defaults to enabled. The effective host allowlist is the set derived from
178+
the bind address (see :func:`default_allowed_hosts`) unioned with
179+
``allowed_hosts``. ``stdio`` has no network surface and is never affected.
180+
"""
181+
182+
enabled: bool = True
183+
# Extra hosts accepted in addition to the bind-derived default allowlist.
184+
# Needed for reverse-proxy / non-loopback deployments whose public Host
185+
# differs from the bind address.
186+
allowed_hosts: list[str] = Field(default_factory=list)
187+
# Exact ``Origin`` values accepted from browser clients. Empty means no
188+
# cross-site browser origin is allowed; requests with no Origin (typical of
189+
# non-browser MCP clients) always pass.
190+
allowed_origins: list[str] = Field(default_factory=list)
191+
# Escape hatch for trusted-proxy setups that terminate/validate Origin
192+
# upstream. Distinct from an empty allowlist so "no origins configured"
193+
# (fail closed on any Origin) differs from "operator disabled the check".
194+
allow_any_origin: bool = False
195+
196+
168197
class MCPServerConfig(BaseModel):
169198
"""Server-level bootstrap configuration."""
170199

171200
redis_url: str = Field(..., min_length=1)
172201
auth: MCPAuthConfig | None = None
202+
transport_security: MCPTransportSecurityConfig | None = None
173203

174204

175205
class MCPIndexSearchConfig(BaseModel):

redisvl/mcp/server.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
from redisvl.mcp.settings import MCPSettings
1515
from redisvl.mcp.tools.search import register_search_tool
1616
from redisvl.mcp.tools.upsert import register_upsert_tool
17+
from redisvl.mcp.transport_security import (
18+
build_host_origin_middleware,
19+
resolve_transport_security_config,
20+
)
1721
from redisvl.redis.connection import RedisConnectionFactory, is_version_gte
1822
from redisvl.schema import IndexSchema
1923

@@ -84,8 +88,39 @@ def __init__(self, settings: MCPSettings):
8488
self.auth_config = auth_config
8589
self._auth_enabled = auth_provider is not None
8690

91+
# Host/Origin (DNS-rebinding) protection for HTTP transports. Resolved
92+
# here so config errors surface at construction; the bind-derived host
93+
# allowlist is finalized in run_async once host/port are known.
94+
self._transport_security = resolve_transport_security_config(
95+
settings, self._config_path
96+
)
97+
8798
super().__init__("redisvl", lifespan=self._fastmcp_lifespan, auth=auth_provider)
8899

100+
async def run_async(
101+
self,
102+
transport: Any = None,
103+
show_banner: bool | None = None,
104+
**transport_kwargs: Any,
105+
) -> None:
106+
"""Run the server, injecting Host/Origin validation for HTTP transports.
107+
108+
The guard is prepended to any caller-supplied middleware so it runs
109+
outermost, rejecting DNS-rebinding requests before auth or tool handlers.
110+
``stdio`` is untouched.
111+
"""
112+
if transport in ("sse", "streamable-http"):
113+
host = transport_kwargs.get("host", "127.0.0.1")
114+
port = transport_kwargs.get("port", 8000)
115+
guard = build_host_origin_middleware(self._transport_security, host, port)
116+
if guard:
117+
existing = transport_kwargs.get("middleware") or []
118+
transport_kwargs["middleware"] = [*guard, *existing]
119+
120+
await super().run_async(
121+
transport=transport, show_banner=show_banner, **transport_kwargs
122+
)
123+
89124
async def startup(self) -> None:
90125
"""Load config, inspect the configured index, and initialize dependencies."""
91126
async with self._transition_lock:

redisvl/mcp/settings.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ class MCPSettings(BaseSettings):
3333
auth_authorization_claim: str | None = None
3434
auth_base_url: str | None = None
3535

36+
# Transport security overrides (``REDISVL_MCP_*``). When any are set they
37+
# take precedence over the YAML ``server.transport_security`` block.
38+
transport_security_enabled: bool | None = None
39+
allowed_hosts: str | None = None
40+
allowed_origins: str | None = None
41+
allow_any_origin: bool | None = None
42+
3643
@classmethod
3744
def from_env(
3845
cls,
@@ -86,3 +93,24 @@ def auth_overrides(self) -> dict[str, Any]:
8693
item.strip() for item in env_value.split(",") if item.strip()
8794
]
8895
return overrides
96+
97+
def transport_security_overrides(self) -> dict[str, Any]:
98+
"""Return the non-``None`` transport-security fields as a config mapping.
99+
100+
Comma-separated ``allowed_hosts`` / ``allowed_origins`` are split into
101+
lists. Returns an empty dict when no transport-security env vars are set.
102+
"""
103+
overrides: dict[str, Any] = {}
104+
if self.transport_security_enabled is not None:
105+
overrides["enabled"] = self.transport_security_enabled
106+
if self.allow_any_origin is not None:
107+
overrides["allow_any_origin"] = self.allow_any_origin
108+
for env_value, field in (
109+
(self.allowed_hosts, "allowed_hosts"),
110+
(self.allowed_origins, "allowed_origins"),
111+
):
112+
if env_value is not None:
113+
overrides[field] = [
114+
item.strip() for item in env_value.split(",") if item.strip()
115+
]
116+
return overrides

0 commit comments

Comments
 (0)