fix(mcp): Host 白名单加入 APP_BASE_URL 域名
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled

rmcp 的 DNS-rebinding 防护默认仅允许 localhost/127.0.0.1/::1,
生产反代 proxy_set_header Host $host 会把真实域名转发进来被 403。
现把 APP_BASE_URL 的 host 解析加入 allowed_hosts(保留默认值),
本地直连与生产域名同时放行。

注:本次只修服务端潜在 prod 403;用户当前 0.0.0.0 的 403 是客户端
配置误用了 bind 地址,应改用 localhost/域名。
This commit is contained in:
xfy 2026-07-28 16:45:49 +08:00
parent cc88a7e90a
commit 6d520c3d48

View File

@ -1,11 +1,16 @@
//! 构建并返回挂载于 `/mcp` 的 axum 子路由。 //! 构建并返回挂载于 `/mcp` 的 axum 子路由。
//! //!
//! 装配: //! 装配:
//! - rmcp `StreamableHttpService`无状态、JSON 响应、Origin 白名单 //! - rmcp `StreamableHttpService`无状态、JSON 响应、Origin 白名单 + Host 白名单
//! - 本 crate 的 `mcp_auth_middleware`bearer → McpPrincipal注入 extensions //! - 本 crate 的 `mcp_auth_middleware`bearer → McpPrincipal注入 extensions
//! //!
//! Origin 白名单:优先 `APP_BASE_URL`否则放空rmcp 默认对缺 Origin 放行)。 //! 两道白名单都源自 `APP_BASE_URL`
//! 生产部署 MUST 设置 `APP_BASE_URL`(见 docs/DEPLOYMENT.md白名单才会生效。 //! - **Origin**:整串(含 schemermcp 按 scheme+host 校验 `Origin` 头;
//! - **Host**:仅 host 部分,加入 rmcp 的 DNS-rebinding 防护默认表localhost/127.0.0.1/::1
//! 生产反代 `proxy_set_header Host $host` 会把真实域名转发进来,必须加入否则 rmcp 一律 403。
//!
//! 未设置 `APP_BASE_URL` 则两者均空rmcp 对缺 Origin 放行Host 回退默认表仅本地可用)。
//! 生产部署 MUST 设置 `APP_BASE_URL`(见 docs/DEPLOYMENT.md
//! 协议版本头、体积上限4MiB 默认)由 rmcp 内置,无需此处重复。 //! 协议版本头、体积上限4MiB 默认)由 rmcp 内置,无需此处重复。
use axum::middleware::from_fn; use axum::middleware::from_fn;
@ -20,17 +25,37 @@ use crate::mcp::server::YggMcpServer;
/// ///
/// 仅在 server feature 下有意义WASM 构建不会调用本函数。 /// 仅在 server feature 下有意义WASM 构建不会调用本函数。
pub fn mcp_route() -> Router { pub fn mcp_route() -> Router {
// 计算 Origin 白名单APP_BASE_URL 规范化后加入未设置则空rmcp 对缺 Origin 放行)。 // 读取 APP_BASE_URL同时用于 Origin 白名单(整串含 scheme与 Host 白名单(仅 host)。
let allowed_origins: Vec<String> = std::env::var("APP_BASE_URL") let base_url = std::env::var("APP_BASE_URL")
.ok() .ok()
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty());
.into_iter()
.collect(); // Origin 白名单APP_BASE_URL 整串(含 schemermcp 按 scheme+host[:port] 匹配 Origin 头。
// 未设置则空——rmcp 对缺 Origin 头的请求放行(大多数原生 MCP 客户端不发 Origin
let allowed_origins: Vec<String> = base_url.iter().cloned().collect();
// Host 白名单rmcp 的 DNS-rebinding 防护)。默认仅允许 localhost/127.0.0.1/::1
// 生产反代nginx `proxy_set_header Host $host`)会把真实域名转发进来,若不把
// APP_BASE_URL 的 host 也加入,所有 MCP 请求会被 rmcp 以 403 拒绝。保留默认值,
// 确保本地直连localhost仍可用只取 host不含 portrmcp 对无 port 条目匹配任意 port。
let mut allowed_hosts: Vec<String> =
vec!["localhost".into(), "127.0.0.1".into(), "::1".into()];
if let Some(base) = &base_url {
if let Ok(uri) = http::Uri::try_from(base.as_str()) {
if let Some(authority) = uri.authority() {
let host = authority.host().to_lowercase();
if !allowed_hosts.contains(&host) {
allowed_hosts.push(host);
}
}
}
}
let mut config = StreamableHttpServerConfig::default() let mut config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(false) // 无状态SEP-2567 .with_legacy_session_mode(false) // 无状态SEP-2567
.with_json_response(true); // 简单工具用 application/json 直回 .with_json_response(true) // 简单工具用 application/json 直回
.with_allowed_hosts(allowed_hosts.iter().map(|s| s.as_str()));
if !allowed_origins.is_empty() { if !allowed_origins.is_empty() {
config = config.with_allowed_origins(allowed_origins.iter().map(|s| s.as_str())); config = config.with_allowed_origins(allowed_origins.iter().map(|s| s.as_str()));
} }