feat(security): 部署安全加固(排除 token 项)

- 安全响应头中间件: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 校验)
This commit is contained in:
oudecheng 2026-08-17 06:47:35 +08:00
parent 1019dbe8cc
commit f2fc5e97ac
5 changed files with 310 additions and 18 deletions

View File

@ -623,6 +623,11 @@ pub struct GatewayConfig {
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
#[serde(default, rename = "auth_token")]
pub auth_token: Option<String>,
/// 远程部署时允许的跨域来源白名单(如 ["https://bot.example.com"])。
/// 绑定非 loopback 时生效:配置后 CORS 仅放行列出的 origin
/// 未配置则允许任意 origin此时由 auth_token 提供保护,启动时会打 warn 日志提示加固)。
#[serde(default, rename = "allowed_origins")]
pub allowed_origins: Option<Vec<String>>,
}
#[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,
}
}
}

View File

@ -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 <token>`HTTP或 `?token=<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<String>,
}
/// 提取 Origin 头的 authorityhost[: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.1Origin 为公网域名,拒绝
assert!(!ws_origin_loopback(&make_origin_host_headers(
Some("http://evil.com"),
Some("127.0.0.1:19876")
)));
// DNS rebindingHost/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")
)));
}
}

View File

@ -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<String>,
port: Option<u16>,
@ -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))
};
// CORSloopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。
// 不论哪种情况都显式设置以避免浏览器默认行为差异
// CORSloopback 下仅允许 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 保护
// 远程访问:优先 origin 白名单;未配置时允许任意来源(由 token 保护)
let origins: Vec<HeaderValue> = allowed_origins
.iter()
.flatten()
.filter_map(|o| match o.parse::<HeaderValue>() {
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 = {

View File

@ -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 单条消息大小上限。
/// 前端附件上传上限 50MBbase64 编码后约 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<Arc<GatewayState>>,
Query(query): Query<WsAuthQuery>,
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
) -> 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,9 +159,22 @@ 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 {
// 无 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;
})
}
@ -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 实际接收的完全一致

View File

@ -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;