Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 1b93f2f

Browse files
authored
feat(http)!: enforce Origin header server-side via rmcp 1.6.0 (#137)
* feat(http)!: enforce Origin header server-side via rmcp 1.6.0 Wire --allowed-origins into rmcp 1.6.0's StreamableHttpServerConfig::with_allowed_origins so the same allowlist that powers tower-http CORS preflight now also drives server-side Origin rejection. The two layers cannot disagree because they share a single Vec<String>. Extracted build_http_router from HttpCommand::execute to make the router callable from tests via tower::ServiceExt::oneshot, giving hermetic network-free coverage of the rejection paths and the allowed/missing-Origin pass-through. BREAKING CHANGE: requests carrying a forbidden Origin are now rejected with 403 at the MCP transport layer, not just blocked at CORS preflight. The default --allowed-origins list (loopback) is unchanged, so local AI-assistant integrations are not affected. Operators who need broader access must extend --allowed-origins or disable validation explicitly via --allowed-origins "". * docs(http): note server-side Origin and Host enforcement Update README, configuration docs, features overview, and the HttpConfig struct doc comment to reflect that --allowed-origins now drives both CORS preflight and rmcp server-side Origin rejection, and that --allowed-hosts is enforced server-side with HTTP/2 :authority fallback.
1 parent d5f2725 commit 1b93f2f

8 files changed

Lines changed: 198 additions & 22 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "runtime-tokio-rustls"] }
5151
thiserror = "2"
5252
tokio = { version = "1", features = ["full"] }
5353
tokio-util = { version = "0.7", features = ["rt"] }
54+
tower = { version = "0.5", features = ["util"] }
5455
tower-http = { version = "0.6", features = ["cors"] }
5556
tracing = "0.1"
5657
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -103,6 +104,7 @@ indexmap = { workspace = true }
103104
insta = { workspace = true }
104105
rmcp = { workspace = true, features = ["client"] }
105106
serde_json = { workspace = true }
107+
tower = { workspace = true }
106108

107109
[[test]]
108110
name = "functional_mysql"

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ A subcommand is required — running `dbmcp` with no subcommand prints usage hel
172172
|------|---------|-------------|
173173
| `--host` | `127.0.0.1` | Bind host |
174174
| `--port` | `9001` | Bind port |
175-
| `--allowed-origins` | localhost variants | CORS allowed origins (comma-separated) |
176-
| `--allowed-hosts` | `localhost,127.0.0.1,::1` | Trusted Host headers (comma-separated) |
175+
| `--allowed-origins` | localhost variants | Allowed browser origins (comma-separated). Drives both CORS preflight and server-side Origin rejection. |
176+
| `--allowed-hosts` | `localhost,127.0.0.1,::1` | Trusted Host headers (comma-separated). Enforced server-side; HTTP/2 `:authority` is honored. |
177177

178178
## MCP Tools 🧩
179179

@@ -289,7 +289,7 @@ Returns the execution plan for a SQL query. Supports an optional `analyze` param
289289
- **Single-statement enforcement** — multi-statement injection blocked at parse level
290290
- **Dangerous function blocking**`LOAD_FILE()`, `INTO OUTFILE`, `INTO DUMPFILE` detected in the AST
291291
- **Identifier validation** — database/table names validated against control characters and empty strings
292-
- **CORS + trusted hosts** — configurable for HTTP transport
292+
- **Origin + Host allowlists** server-side rejection (403) plus CORS preflight; configurable for HTTP transport
293293
- **SSL/TLS** — configured via individual `DB_SSL_*` variables
294294
- **Credential redaction** — database password is never shown in logs or debug output
295295

crates/config/src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ pub struct HttpConfig {
272272
/// Bind port for HTTP transport.
273273
pub port: u16,
274274

275-
/// Allowed CORS origins.
275+
/// Allowed browser origins for both CORS preflight and rmcp server-side validation.
276276
pub allowed_origins: Vec<String>,
277277

278278
/// Allowed host names.

docs/content/docs/configuration.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ These options are available only when running in HTTP mode (`dbmcp http`):
8080
|----------|---------|-------------|
8181
| `--host` | `127.0.0.1` | Bind host for the HTTP server |
8282
| `--port` | `9001` | Bind port for the HTTP server |
83-
| `--allowed-origins` | `http://localhost,http://127.0.0.1,https://localhost,https://127.0.0.1` | Allowed CORS origins (comma-separated) |
84-
| `--allowed-hosts` | `localhost,127.0.0.1,::1` | Allowed host names (comma-separated) |
83+
| `--allowed-origins` | `http://localhost,http://127.0.0.1,https://localhost,https://127.0.0.1` | Allowed browser origins (comma-separated). Drives both CORS preflight and server-side `Origin` header rejection. |
84+
| `--allowed-hosts` | `localhost,127.0.0.1,::1` | Allowed host names (comma-separated). Enforced server-side; HTTP/2 `:authority` is honored. |
8585

8686
A transport subcommand is required — running `dbmcp` with no subcommand prints usage help and exits with a non-zero status. The `stdio` transport requires no additional configuration beyond the database options above.
8787

docs/content/docs/features.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ The server communicates over standard input/output. This mode works with local M
378378

379379
### HTTP
380380

381-
The server runs as an HTTP service with Streamable HTTP transport and CORS support. This mode is useful for remote access or shared environments where multiple clients connect to the same server.
381+
The server runs as an HTTP service with Streamable HTTP transport, CORS preflight, and server-side `Origin`/`Host` header allowlists. This mode is useful for remote access or shared environments where multiple clients connect to the same server.
382382

383383
**Best for**: Remote servers, shared team databases, environments where the MCP client cannot launch local processes.
384384

docs/content/docs/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Database MCP is a [Model Context Protocol](https://modelcontextprotocol.io/) (MC
1616

1717
- **Single binary** — no runtime dependencies, no language runtimes to install
1818
- **Read-only by default** — write operations are blocked unless explicitly enabled
19-
- **Stdio and HTTP transport** — works as a local stdio server or a remote HTTP server with CORS support
19+
- **Stdio and HTTP transport** — works as a local stdio server or a remote HTTP server with CORS preflight and server-side Origin/Host allowlists
2020
- **Multi-database support** — connect to MySQL, MariaDB, PostgreSQL, or SQLite with a single binary
2121

2222
## How It Works

src/commands/http.rs

Lines changed: 187 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ struct HttpArguments {
4545
)]
4646
port: u16,
4747

48-
/// Allowed CORS origins (comma-separated).
48+
/// Allowed browser origins (comma-separated, RFC 6454 `<scheme>://<host>[:<port>]` or `null`).
49+
///
50+
/// Drives BOTH the CORS preflight allowlist AND the rmcp server-side
51+
/// Origin validator. Pass an empty list (`--allowed-origins ""`) to
52+
/// disable both layers — only do this for non-browser deployments.
4953
#[arg(
5054
long = "allowed-origins",
5155
env = "HTTP_ALLOWED_ORIGINS",
@@ -111,19 +115,7 @@ impl HttpCommand {
111115
let server = common::create_server(&db_config);
112116
let cancel_token = CancellationToken::new();
113117

114-
let service = StreamableHttpService::new(
115-
move || Ok(server.clone()),
116-
Arc::new(LocalSessionManager::default()),
117-
StreamableHttpServerConfig::default()
118-
.with_stateful_mode(false)
119-
.with_json_response(true)
120-
.with_cancellation_token(cancel_token.child_token())
121-
.with_allowed_hosts(http_config.allowed_hosts.clone()),
122-
);
123-
124-
let router = axum::Router::new()
125-
.nest_service("/mcp", service)
126-
.layer(build_cors_layer(&http_config));
118+
let router = build_http_router(&http_config, server, &cancel_token);
127119

128120
let bind_addr = format!("{}:{}", http_config.host, http_config.port);
129121
info!("Starting MCP server via HTTP transport on {bind_addr}...");
@@ -142,6 +134,32 @@ impl HttpCommand {
142134
}
143135
}
144136

137+
/// Builds the axum router that serves MCP over Streamable HTTP.
138+
///
139+
/// Wires the configured allowed-origins list into BOTH the rmcp 1.6.0
140+
/// server-side Origin validator and the tower-http CORS preflight layer
141+
/// so the two layers cannot disagree by accident.
142+
fn build_http_router(
143+
http_config: &HttpConfig,
144+
server: common::Server,
145+
cancel_token: &CancellationToken,
146+
) -> axum::Router {
147+
let service = StreamableHttpService::new(
148+
move || Ok(server.clone()),
149+
Arc::new(LocalSessionManager::default()),
150+
StreamableHttpServerConfig::default()
151+
.with_stateful_mode(false)
152+
.with_json_response(true)
153+
.with_cancellation_token(cancel_token.child_token())
154+
.with_allowed_hosts(http_config.allowed_hosts.clone())
155+
.with_allowed_origins(http_config.allowed_origins.clone()),
156+
);
157+
158+
axum::Router::new()
159+
.nest_service("/mcp", service)
160+
.layer(build_cors_layer(http_config))
161+
}
162+
145163
/// Builds a CORS layer from the configured allowed origins.
146164
fn build_cors_layer(http_config: &HttpConfig) -> CorsLayer {
147165
let origins: Vec<axum::http::HeaderValue> = http_config
@@ -187,3 +205,158 @@ async fn shutdown_signal() {
187205
() = terminate => info!("SIGTERM received, shutting down..."),
188206
}
189207
}
208+
209+
#[cfg(test)]
210+
mod tests {
211+
use super::*;
212+
213+
use axum::body::{Body, to_bytes};
214+
use axum::http::{HeaderValue, Method, Request, StatusCode, header, request::Builder};
215+
use clap::Parser;
216+
use dbmcp_config::DatabaseBackend;
217+
use tower::ServiceExt;
218+
219+
#[derive(Parser)]
220+
#[command(no_binary_name = true)]
221+
struct TestCli {
222+
#[command(flatten)]
223+
http: HttpArguments,
224+
}
225+
226+
fn parse_http_args(args: &[&str]) -> HttpArguments {
227+
// SAFETY: tests don't run concurrently against these env vars.
228+
unsafe {
229+
std::env::remove_var("HTTP_ALLOWED_ORIGINS");
230+
std::env::remove_var("HTTP_ALLOWED_HOSTS");
231+
}
232+
TestCli::try_parse_from(args).expect("clap parse").http
233+
}
234+
235+
fn sqlite_memory_db_config() -> DatabaseConfig {
236+
DatabaseConfig {
237+
backend: DatabaseBackend::Sqlite,
238+
name: Some(":memory:".into()),
239+
..DatabaseConfig::default()
240+
}
241+
}
242+
243+
fn router_with_origins(origins: Vec<String>) -> axum::Router {
244+
let http_config = HttpConfig {
245+
host: HttpConfig::DEFAULT_HOST.into(),
246+
port: HttpConfig::DEFAULT_PORT,
247+
allowed_origins: origins,
248+
allowed_hosts: HttpConfig::default_allowed_hosts(),
249+
};
250+
let server = common::create_server(&sqlite_memory_db_config());
251+
let cancel = CancellationToken::new();
252+
build_http_router(&http_config, server, &cancel)
253+
}
254+
255+
async fn send(router: axum::Router, request: Request<Body>) -> (StatusCode, String) {
256+
let response = router.oneshot(request).await.expect("oneshot");
257+
let status = response.status();
258+
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.expect("body bytes");
259+
(status, String::from_utf8_lossy(&bytes).into_owned())
260+
}
261+
262+
fn mcp_post(uri: &str) -> Builder {
263+
Request::builder()
264+
.method(Method::POST)
265+
.uri(uri)
266+
.header(header::HOST, "localhost")
267+
.header(header::CONTENT_TYPE, "application/json")
268+
.header(header::ACCEPT, "application/json, text/event-stream")
269+
}
270+
271+
#[test]
272+
fn clap_default_yields_four_loopback_origins() {
273+
let args = parse_http_args(&[]);
274+
let config = HttpConfig::try_from(&args).expect("default config valid");
275+
assert_eq!(config.allowed_origins, HttpConfig::default_allowed_origins());
276+
}
277+
278+
#[tokio::test]
279+
async fn disallowed_origin_returns_403() {
280+
let router = router_with_origins(HttpConfig::default_allowed_origins());
281+
let request = mcp_post("/mcp/")
282+
.header(header::ORIGIN, "https://evil.example")
283+
.body(Body::from("{}"))
284+
.expect("build request");
285+
let (status, body) = send(router, request).await;
286+
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
287+
assert_eq!(body, "Forbidden: Origin header is not allowed");
288+
}
289+
290+
#[tokio::test]
291+
async fn disallowed_host_returns_403() {
292+
let router = router_with_origins(HttpConfig::default_allowed_origins());
293+
let request = Request::builder()
294+
.method(Method::POST)
295+
.uri("/mcp/")
296+
.header(header::HOST, "evil.example")
297+
.header(header::CONTENT_TYPE, "application/json")
298+
.header(header::ACCEPT, "application/json, text/event-stream")
299+
.body(Body::from("{}"))
300+
.expect("build request");
301+
let (status, body) = send(router, request).await;
302+
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
303+
assert_eq!(body, "Forbidden: Host header is not allowed");
304+
}
305+
306+
#[tokio::test]
307+
async fn malformed_origin_non_utf8_returns_400() {
308+
let router = router_with_origins(HttpConfig::default_allowed_origins());
309+
let mut request = mcp_post("/mcp/").body(Body::from("{}")).expect("build request");
310+
request.headers_mut().insert(
311+
header::ORIGIN,
312+
HeaderValue::from_bytes(&[0xFF, 0xFE]).expect("non-utf8 header value"),
313+
);
314+
let (status, body) = send(router, request).await;
315+
assert_eq!(status, StatusCode::BAD_REQUEST, "body={body}");
316+
assert_eq!(body, "Bad Request: Invalid Origin header encoding");
317+
}
318+
319+
#[tokio::test]
320+
async fn malformed_origin_unparseable_returns_400() {
321+
let router = router_with_origins(HttpConfig::default_allowed_origins());
322+
let request = mcp_post("/mcp/")
323+
.header(header::ORIGIN, "not a url")
324+
.body(Body::from("{}"))
325+
.expect("build request");
326+
let (status, body) = send(router, request).await;
327+
assert_eq!(status, StatusCode::BAD_REQUEST, "body={body}");
328+
assert_eq!(body, "Bad Request: Invalid Origin header");
329+
}
330+
331+
#[tokio::test]
332+
async fn allowed_origin_passes_validator() {
333+
let router = router_with_origins(HttpConfig::default_allowed_origins());
334+
let request = mcp_post("/mcp/")
335+
.header(header::ORIGIN, "http://localhost")
336+
.body(Body::from("{}"))
337+
.expect("build request");
338+
let (status, _body) = send(router, request).await;
339+
assert_ne!(status, StatusCode::FORBIDDEN);
340+
assert_ne!(status, StatusCode::BAD_REQUEST);
341+
}
342+
343+
#[tokio::test]
344+
async fn missing_origin_passes_validator() {
345+
let router = router_with_origins(HttpConfig::default_allowed_origins());
346+
let request = mcp_post("/mcp/").body(Body::from("{}")).expect("build request");
347+
let (status, _body) = send(router, request).await;
348+
assert_ne!(status, StatusCode::FORBIDDEN);
349+
assert_ne!(status, StatusCode::BAD_REQUEST);
350+
}
351+
352+
#[tokio::test]
353+
async fn empty_allowlist_skips_origin_check() {
354+
let router = router_with_origins(vec![]);
355+
let request = mcp_post("/mcp/")
356+
.header(header::ORIGIN, "https://evil.example")
357+
.body(Body::from("{}"))
358+
.expect("build request");
359+
let (status, _body) = send(router, request).await;
360+
assert_ne!(status, StatusCode::FORBIDDEN, "empty allowlist must not reject Origin");
361+
}
362+
}

0 commit comments

Comments
 (0)