From f2fc5e97ac42864036a7d80a5e7e3e53a848aaaa Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 17 Aug 2026 06:47:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E9=83=A8=E7=BD=B2=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=8A=A0=E5=9B=BA=EF=BC=88=E6=8E=92=E9=99=A4=20token?= =?UTF-8?q?=20=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安全响应头中间件:nosniff / X-Frame-Options DENY / Referrer-Policy / CSP / Permissions-Policy,最外层覆盖所有响应 - loopback CORS 由 mirror_request 改为 loopback origin 白名单(原实现会回显任意 Origin,允许恶意网页跨域读取本地网关) - 新增 gateway.allowed_origins 配置:非 loopback 部署可收紧 CORS,未配置时保持 permissive 并打 warn - loopback 免认证模式强制 Host 头为 loopback,阻断 DNS rebinding - loopback 免认证模式 WS Origin 必须为 loopback 来源,防跨站 WebSocket 劫持(CSWSH) - WS 消息/帧大小显式上限 80MiB/16MiB(顺带修复 50MB 附件 base64 超 tungstenite 默认 64MB 上限的问题) - HTTP 请求体显式限制 2MB(DefaultBodyLimit) - 前端 HTTPS 页面自动使用 wss://,避免混合内容拦截 WebSocket - 新增 10 个安全相关单元测试(host/origin loopback 判定、WS Origin 校验) --- src/config/mod.rs | 6 + src/gateway/auth.rs | 168 +++++++++++++++++- src/gateway/mod.rs | 118 ++++++++++-- src/gateway/ws.rs | 32 +++- web/src/components/Settings/SettingsModal.tsx | 4 +- 5 files changed, 310 insertions(+), 18 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 3a382a3..38d966f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -623,6 +623,11 @@ pub struct GatewayConfig { /// 前端通过 Authorization: Bearer 头或 WS query param ?token= 携带。 #[serde(default, rename = "auth_token")] pub auth_token: Option, + /// 远程部署时允许的跨域来源白名单(如 ["https://bot.example.com"])。 + /// 绑定非 loopback 时生效:配置后 CORS 仅放行列出的 origin; + /// 未配置则允许任意 origin(此时由 auth_token 提供保护,启动时会打 warn 日志提示加固)。 + #[serde(default, rename = "allowed_origins")] + pub allowed_origins: Option>, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -933,6 +938,7 @@ impl Default for GatewayConfig { max_concurrent_requests: default_max_concurrent_requests(), session_ttl_hours: Some(24), auth_token: None, + allowed_origins: None, } } } diff --git a/src/gateway/auth.rs b/src/gateway/auth.rs index 66a6e02..d36ba6f 100644 --- a/src/gateway/auth.rs +++ b/src/gateway/auth.rs @@ -1,14 +1,15 @@ //! 网关认证与访问控制。 //! //! 设计目标(第一性原理): -//! - 本地单机部署(host 为 loopback):免认证,仅靠 CORS 防御 DNS rebinding / CSRF。 +//! - 本地单机部署(host 为 loopback):免认证,靠 Host 头 loopback 校验(防 DNS rebinding) +//! + CORS loopback origin 白名单(防跨域读取)+ WS Origin loopback 校验(防 CSWSH)。 //! - 远程访问(host 非 loopback):必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。 //! - token 通过 `Authorization: Bearer `(HTTP)或 `?token=`(WS)传递。 //! - 校验使用常量时间比较,避免计时侧信道。 use axum::Json; use axum::extract::Request; -use axum::http::{HeaderMap, StatusCode}; +use axum::http::{HeaderMap, StatusCode, header}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use serde_json::json; @@ -122,6 +123,85 @@ pub struct AuthConfig { pub token: Option, } +/// 提取 Origin 头的 authority(host[:port])部分。 +/// 格式如 `https://bot.example.com:8443/path` -> `bot.example.com:8443`。 +/// 无法解析(如隐私上下文下的 `Origin: null`)时返回 None。 +pub(crate) fn origin_authority(origin: &str) -> Option<&str> { + let rest = origin.split_once("://").map(|(_, r)| r)?; + let authority = rest.split(['/', '?', '#']).next()?.trim(); + if authority.is_empty() { + None + } else { + Some(authority) + } +} + +/// 判定 `host[:port]` 形式的主机值是否为 loopback(供 Host 头 / Origin authority 复用)。 +/// - `127.0.0.1:19876` / `localhost:19876` -> true +/// - `[::1]:19876` -> true +/// - `evil.com:19876` -> false +/// - 无括号的裸 IPv6(如 `::1`)按最后一个冒号拆分,行为保守(浏览器 Host 头恒带括号) +pub fn host_port_is_loopback(host_value: &str) -> bool { + let host_value = host_value.trim(); + let host_part = if let Some(rest) = host_value.strip_prefix('[') { + match rest.split_once(']') { + Some((inner, _)) => inner, + None => return false, + } + } else { + match host_value.rsplit_once(':') { + Some((h, port_like)) if port_like.chars().all(|c| c.is_ascii_digit()) => h, + _ => host_value, + } + }; + is_loopback_host(host_part) +} + +/// 判定 Origin 值是否为 loopback 来源(authority 的主机部分属于 loopback 段,端口不限)。 +/// `Origin: null` 或无法解析时返回 false。 +pub fn origin_is_loopback(origin: &str) -> bool { + origin_authority(origin.trim()) + .map(host_port_is_loopback) + .unwrap_or(false) +} + +/// 校验 WebSocket 升级请求的 Origin(防跨站 WebSocket 劫持 CSWSH)。 +/// 仅在无 token 认证保护时(loopback 免认证模式)调用: +/// +/// - 无 Origin 头:放行(非浏览器客户端不发送 Origin) +/// - Origin 为 loopback 来源:放行(覆盖同源、localhost↔127.0.0.1、vite dev 等合法场景) +/// - 其余(公网域名、`Origin: null`):拒绝。 +/// +/// 注意不能用 Origin==Host 相等判定:DNS rebinding 下两者一致, +/// 而合法开发场景中两者常常不同(localhost:5173 → 127.0.0.1:19876)。 +pub fn ws_origin_loopback(headers: &HeaderMap) -> bool { + let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { + return true; + }; + origin_is_loopback(origin) +} + +/// axum 中间件:强制 Host 头为 loopback(仅在 loopback 免认证模式下挂载)。 +/// 阻断 DNS rebinding:重绑定后浏览器发送的 Host 为攻击者域名,直接拒绝。 +/// 无 Host 头的请求同样拒绝(浏览器恒发送 Host)。 +pub async fn require_loopback_host(headers: HeaderMap, request: Request, next: Next) -> Response { + let host_ok = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .map(host_port_is_loopback) + .unwrap_or(false); + if host_ok { + next.run(request).await + } else { + tracing::warn!("Request rejected: Host header is not loopback (possible DNS rebinding)"); + ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "forbidden", "message": "Host header must be loopback" })), + ) + .into_response() + } +} + #[cfg(test)] mod tests { use super::*; @@ -238,4 +318,88 @@ mod tests { assert_eq!(extract_bearer_token(&make_auth_header("Bearer")), None); assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some("")); } + + fn make_origin_host_headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap { + let mut h = HeaderMap::new(); + if let Some(o) = origin { + h.insert( + axum::http::header::ORIGIN, + axum::http::HeaderValue::from_str(o).unwrap(), + ); + } + if let Some(host) = host { + h.insert( + axum::http::header::HOST, + axum::http::HeaderValue::from_str(host).unwrap(), + ); + } + h + } + + #[test] + fn host_port_loopback_detection() { + assert!(host_port_is_loopback("127.0.0.1:19876")); + assert!(host_port_is_loopback("127.0.0.1")); + assert!(host_port_is_loopback("localhost:19876")); + assert!(host_port_is_loopback("[::1]:19876")); + assert!(host_port_is_loopback(" localhost:19876 ")); + assert!(!host_port_is_loopback("evil.com:19876")); + assert!(!host_port_is_loopback("evil.com")); + assert!(!host_port_is_loopback("192.168.1.1:19876")); + assert!(!host_port_is_loopback("")); + // 未闭合括号:拒绝 + assert!(!host_port_is_loopback("[::1:19876")); + } + + #[test] + fn origin_loopback_detection() { + assert!(origin_is_loopback("http://127.0.0.1:19876")); + assert!(origin_is_loopback("http://localhost:5173")); + assert!(origin_is_loopback("http://[::1]:3000")); + // 公网域名:拒绝 + assert!(!origin_is_loopback("http://evil.com")); + assert!(!origin_is_loopback("https://evil.com:8443/path")); + // Origin: null(隐私上下文):拒绝 + assert!(!origin_is_loopback("null")); + assert!(!origin_is_loopback("")); + } + + #[test] + fn ws_origin_check() { + // 无 Origin(非浏览器客户端):放行 + assert!(ws_origin_loopback(&make_origin_host_headers( + None, + Some("127.0.0.1:19876") + ))); + // 同源:放行 + assert!(ws_origin_loopback(&make_origin_host_headers( + Some("http://127.0.0.1:19876"), + Some("127.0.0.1:19876") + ))); + // vite dev 场景:localhost:5173 → 127.0.0.1:19876,双方都是 loopback:放行 + assert!(ws_origin_loopback(&make_origin_host_headers( + Some("http://localhost:5173"), + Some("127.0.0.1:19876") + ))); + // localhost ↔ 127.0.0.1 混用:放行 + assert!(ws_origin_loopback(&make_origin_host_headers( + Some("http://localhost:19876"), + Some("127.0.0.1:19876") + ))); + // 恶意页面直连 127.0.0.1:Origin 为公网域名,拒绝 + assert!(!ws_origin_loopback(&make_origin_host_headers( + Some("http://evil.com"), + Some("127.0.0.1:19876") + ))); + // DNS rebinding:Host/Origin 同为攻击者域名,拒绝 + assert!(!ws_origin_loopback(&make_origin_host_headers( + Some("http://evil.com:19876"), + Some("evil.com:19876") + ))); + // Origin: null:拒绝 + assert!(!ws_origin_loopback(&make_origin_host_headers( + Some("null"), + Some("127.0.0.1:19876") + ))); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index beabfcd..700b7a0 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -31,6 +31,8 @@ pub mod tool_registry_factory; pub mod wait_coordinator; pub mod ws; +use axum::extract::DefaultBodyLimit; +use axum::http::{HeaderName, HeaderValue, header}; use axum::{Router, middleware, routing}; use std::collections::HashMap; use std::sync::Arc; @@ -211,6 +213,57 @@ impl GatewayState { } } +/// HTTP 请求体大小上限(显式声明,与 axum 默认一致,防止隐式依赖默认值)。 +const HTTP_BODY_LIMIT: usize = 2 * 1024 * 1024; + +/// 内容安全策略: +/// - script/object/base/frame 全部限制同源或禁用,缓解 XSS 与点击劫持 +/// - style-src 'unsafe-inline':React 行内 style 属性需要 +/// - img-src 含 data:/blob::聊天附件以 base64 data URL 渲染、下载走 blob URL +/// - connect-src 放开 ws:/wss:/http:/https::前端支持用户自定义网关地址(跨源连接属产品特性) +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \ + script-src 'self'; \ + style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: blob:; \ + font-src 'self' data:; \ + connect-src 'self' ws: wss: http: https:; \ + object-src 'none'; \ + base-uri 'self'; \ + frame-ancestors 'none'"; + +fn insert_header_if_absent( + headers: &mut axum::http::HeaderMap, + name: HeaderName, + value: &'static str, +) { + headers + .entry(name) + .or_insert(HeaderValue::from_static(value)); +} + +/// 安全响应头中间件:为所有响应补充防御性 HTTP 头(已存在的头不覆盖)。 +async fn security_headers( + request: axum::extract::Request, + next: middleware::Next, +) -> axum::response::Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + insert_header_if_absent(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff"); + insert_header_if_absent(headers, header::X_FRAME_OPTIONS, "DENY"); + insert_header_if_absent(headers, header::REFERRER_POLICY, "no-referrer"); + insert_header_if_absent( + headers, + header::CONTENT_SECURITY_POLICY, + CONTENT_SECURITY_POLICY, + ); + insert_header_if_absent( + headers, + HeaderName::from_static("permissions-policy"), + "camera=(), microphone=(), geolocation=()", + ); + response +} + pub async fn run( host: Option, port: Option, @@ -293,11 +346,16 @@ pub async fn run( } // CLI args override config file values - let (bind_host, bind_port, auth_token) = { + let (bind_host, bind_port, auth_token, allowed_origins) = { let cfg = state.config.read().await; let h = host.unwrap_or_else(|| cfg.gateway.host.clone()); let p = port.unwrap_or(cfg.gateway.port); - (h, p, cfg.gateway.auth_token.clone()) + ( + h, + p, + cfg.gateway.auth_token.clone(), + cfg.gateway.allowed_origins.clone(), + ) }; // 安全校验:绑定到非 loopback 地址时必须配置 auth_token @@ -411,22 +469,62 @@ pub async fn run( app.layer(axum::Extension(auth_config)) .layer(middleware::from_fn(auth::require_bearer_auth)) } else { - app + // loopback 免认证模式:强制 Host 头为 loopback,阻断 DNS rebinding + // (重绑定后浏览器发送的 Host 为攻击者域名,会被直接拒绝)。 + app.layer(middleware::from_fn(auth::require_loopback_host)) }; - // CORS:loopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。 - // 不论哪种情况都显式设置以避免浏览器默认行为差异。 + // CORS:loopback 下仅允许 loopback 来源(防恶意网页跨域读取本地网关); + // 非 loopback 下优先使用 allowed_origins 白名单。 let cors = if auth::is_loopback_host(&bind_host) { - // 本地开发:同源即可,阻止跨域(防 DNS rebinding) + // 本地:放行 localhost/127.0.0.1 任意端口(覆盖同源与 vite dev 等场景), + // 拒绝公网 origin——mirror_request 会回显任意 Origin,反而允许跨域读取,不可用。 CorsLayer::new() - .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) + .allow_origin(tower_http::cors::AllowOrigin::predicate( + |origin: &HeaderValue, _parts: &axum::http::request::Parts| { + origin + .to_str() + .map(auth::origin_is_loopback) + .unwrap_or(false) + }, + )) .allow_methods(Any) .allow_headers(Any) } else { - // 远程访问:允许跨域,但由 token 保护 - CorsLayer::permissive() + // 远程访问:优先 origin 白名单;未配置时允许任意来源(由 token 保护) + let origins: Vec = allowed_origins + .iter() + .flatten() + .filter_map(|o| match o.parse::() { + Ok(v) => Some(v), + Err(_) => { + tracing::warn!(origin = %o, "gateway.allowed_origins: 非法 origin 已忽略"); + None + } + }) + .collect(); + if origins.is_empty() { + tracing::warn!( + "未配置 gateway.allowed_origins:远程部署将允许任意来源跨域访问(由 auth_token 保护)。\ + 建议在 config.json 的 gateway 节显式配置允许的来源白名单。" + ); + CorsLayer::permissive() + } else { + tracing::info!( + origins = origins.len(), + "CORS origin whitelist enabled for remote access" + ); + CorsLayer::new() + .allow_origin(origins) + .allow_methods(Any) + .allow_headers(Any) + } }; - let app = app.layer(cors); + // 层序:后加的在外层。安全响应头最外层,覆盖所有响应(含 401/CORS 预检)。 + let app = app + .layer(cors) + .layer(DefaultBodyLimit::max(HTTP_BODY_LIMIT)) + .layer(middleware::from_fn(security_headers)); let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?; let listener = { diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index cc90d4e..a24214b 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -34,7 +34,7 @@ use crate::utils::current_timestamp; use axum::extract::Query; use axum::extract::State; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use futures_util::{SinkExt, StreamExt}; @@ -46,6 +46,13 @@ use tokio_util::sync::CancellationToken; const WS_CHANNEL_NAME: &str = "websocket"; +/// WebSocket 单条消息大小上限。 +/// 前端附件上传上限 50MB,base64 编码后约 67MB,此处留余量取 80MiB; +/// 显式设定边界(而非依赖 tungstenite 默认 64MB),防止超大消息耗尽内存。 +const WS_MAX_MESSAGE_SIZE: usize = 80 * 1024 * 1024; +/// WebSocket 单帧大小上限(消息可由多帧组成)。 +const WS_MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; + /// Default media directory for WebSocket uploads fn default_ws_media_dir() -> PathBuf { let home = crate::platform::picobot_home_dir(); @@ -137,11 +144,13 @@ pub struct WsAuthQuery { pub async fn ws_handler( ws: WebSocketUpgrade, + headers: HeaderMap, State(state): State>, Query(query): Query, auth_cfg: Option>, ) -> Response { // 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token + let mut token_verified = false; if let Some(axum::Extension(cfg)) = auth_cfg && let Some(ref expected) = cfg.token { @@ -150,11 +159,24 @@ pub async fn ws_handler( tracing::warn!("WebSocket connection rejected: missing or invalid token"); return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response(); } + token_verified = true; } - ws.on_upgrade(|socket| async { - handle_socket(socket, state).await; - }) + // 无 token 认证保护时(loopback 免认证模式),要求 Origin 为 loopback 来源, + // 阻断恶意网页直连/DNS rebinding 发起的跨站 WebSocket 劫持(CSWSH)。 + // token 校验通过的连接无需检查(攻击者无法从跨域页面拿到 token)。 + if !token_verified && !crate::gateway::auth::ws_origin_loopback(&headers) { + tracing::warn!( + "WebSocket connection rejected: non-loopback Origin (possible CSWSH/DNS rebinding)" + ); + return (StatusCode::FORBIDDEN, "origin not allowed").into_response(); + } + + ws.max_message_size(WS_MAX_MESSAGE_SIZE) + .max_frame_size(WS_MAX_FRAME_SIZE) + .on_upgrade(|socket| async { + handle_socket(socket, state).await; + }) } async fn handle_socket(ws: WebSocket, state: Arc) { @@ -450,7 +472,7 @@ async fn handle_inbound( .await .get_provider_config("default") .map_err(|e| AgentError::Other(e.to_string()))?; - let prompt_repository = state.session_manager.store().clone(); + let prompt_repository = state.session_manager.store(); // 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、 // /current 保存/展示的系统提示词与 LLM 实际接收的完全一致 diff --git a/web/src/components/Settings/SettingsModal.tsx b/web/src/components/Settings/SettingsModal.tsx index 458c27f..d34c3dd 100644 --- a/web/src/components/Settings/SettingsModal.tsx +++ b/web/src/components/Settings/SettingsModal.tsx @@ -24,7 +24,9 @@ export function getGatewaySettings(): GatewaySettings { } export function buildWsUrl(settings: GatewaySettings): string { - const base = `ws://${settings.host}:${settings.port}/ws`; + // HTTPS 页面下浏览器禁止混合内容(ws://),需使用 wss:// + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const base = `${protocol}://${settings.host}:${settings.port}/ws`; // 远程访问时需要携带认证 token(浏览器原生 WebSocket 不支持自定义 header) const token = getAuthToken(); return token ? `${base}?token=${encodeURIComponent(token)}` : base;