Skip to content

Commit eab122d

Browse files
vishal-balaclaude
andcommitted
feat(mcp): add Host/Origin validation for HTTP transports
Harden the MCP server's streamable-http and SSE transports by validating the Host and Origin request headers against allowlists before any tool call runs. The guard is on by default and auto-derives a safe allowlist from the bind address, so loopback and local clients work with no config. Non-loopback and reverse-proxy deployments can extend the allowlist (or disable the guard) via a server.transport_security config block and REDISVL_MCP_* environment variables, mirroring the existing auth pattern. stdio has no network surface and is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f33868 commit eab122d

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)