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:
parent
1019dbe8cc
commit
f2fc5e97ac
@ -623,6 +623,11 @@ pub struct GatewayConfig {
|
|||||||
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
|
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
|
||||||
#[serde(default, rename = "auth_token")]
|
#[serde(default, rename = "auth_token")]
|
||||||
pub auth_token: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@ -933,6 +938,7 @@ impl Default for GatewayConfig {
|
|||||||
max_concurrent_requests: default_max_concurrent_requests(),
|
max_concurrent_requests: default_max_concurrent_requests(),
|
||||||
session_ttl_hours: Some(24),
|
session_ttl_hours: Some(24),
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
|
allowed_origins: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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` 强制校验。
|
//! - 远程访问(host 非 loopback):必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。
|
||||||
//! - token 通过 `Authorization: Bearer <token>`(HTTP)或 `?token=<token>`(WS)传递。
|
//! - token 通过 `Authorization: Bearer <token>`(HTTP)或 `?token=<token>`(WS)传递。
|
||||||
//! - 校验使用常量时间比较,避免计时侧信道。
|
//! - 校验使用常量时间比较,避免计时侧信道。
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@ -122,6 +123,85 @@ pub struct AuthConfig {
|
|||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提取 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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")), None);
|
||||||
assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some(""));
|
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")
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ pub mod tool_registry_factory;
|
|||||||
pub mod wait_coordinator;
|
pub mod wait_coordinator;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
|
use axum::http::{HeaderName, HeaderValue, header};
|
||||||
use axum::{Router, middleware, routing};
|
use axum::{Router, middleware, routing};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
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(
|
pub async fn run(
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
@ -293,11 +346,16 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CLI args override config file values
|
// 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 cfg = state.config.read().await;
|
||||||
let h = host.unwrap_or_else(|| cfg.gateway.host.clone());
|
let h = host.unwrap_or_else(|| cfg.gateway.host.clone());
|
||||||
let p = port.unwrap_or(cfg.gateway.port);
|
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
|
// 安全校验:绑定到非 loopback 地址时必须配置 auth_token
|
||||||
@ -411,22 +469,62 @@ pub async fn run(
|
|||||||
app.layer(axum::Extension(auth_config))
|
app.layer(axum::Extension(auth_config))
|
||||||
.layer(middleware::from_fn(auth::require_bearer_auth))
|
.layer(middleware::from_fn(auth::require_bearer_auth))
|
||||||
} else {
|
} 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) {
|
let cors = if auth::is_loopback_host(&bind_host) {
|
||||||
// 本地开发:同源即可,阻止跨域(防 DNS rebinding)
|
// 本地:放行 localhost/127.0.0.1 任意端口(覆盖同源与 vite dev 等场景),
|
||||||
|
// 拒绝公网 origin——mirror_request 会回显任意 Origin,反而允许跨域读取,不可用。
|
||||||
CorsLayer::new()
|
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_methods(Any)
|
||||||
.allow_headers(Any)
|
.allow_headers(Any)
|
||||||
} else {
|
} else {
|
||||||
// 远程访问:允许跨域,但由 token 保护
|
// 远程访问:优先 origin 白名单;未配置时允许任意来源(由 token 保护)
|
||||||
CorsLayer::permissive()
|
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 addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
|
||||||
let listener = {
|
let listener = {
|
||||||
|
|||||||
@ -34,7 +34,7 @@ use crate::utils::current_timestamp;
|
|||||||
use axum::extract::Query;
|
use axum::extract::Query;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
@ -46,6 +46,13 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
const WS_CHANNEL_NAME: &str = "websocket";
|
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
|
/// Default media directory for WebSocket uploads
|
||||||
fn default_ws_media_dir() -> PathBuf {
|
fn default_ws_media_dir() -> PathBuf {
|
||||||
let home = crate::platform::picobot_home_dir();
|
let home = crate::platform::picobot_home_dir();
|
||||||
@ -137,11 +144,13 @@ pub struct WsAuthQuery {
|
|||||||
|
|
||||||
pub async fn ws_handler(
|
pub async fn ws_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
|
headers: HeaderMap,
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Query(query): Query<WsAuthQuery>,
|
Query(query): Query<WsAuthQuery>,
|
||||||
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
|
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token
|
// 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token
|
||||||
|
let mut token_verified = false;
|
||||||
if let Some(axum::Extension(cfg)) = auth_cfg
|
if let Some(axum::Extension(cfg)) = auth_cfg
|
||||||
&& let Some(ref expected) = cfg.token
|
&& let Some(ref expected) = cfg.token
|
||||||
{
|
{
|
||||||
@ -150,11 +159,24 @@ pub async fn ws_handler(
|
|||||||
tracing::warn!("WebSocket connection rejected: missing or invalid token");
|
tracing::warn!("WebSocket connection rejected: missing or invalid token");
|
||||||
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
|
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
|
||||||
}
|
}
|
||||||
|
token_verified = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.on_upgrade(|socket| async {
|
// 无 token 认证保护时(loopback 免认证模式),要求 Origin 为 loopback 来源,
|
||||||
handle_socket(socket, state).await;
|
// 阻断恶意网页直连/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<GatewayState>) {
|
async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
||||||
@ -450,7 +472,7 @@ async fn handle_inbound(
|
|||||||
.await
|
.await
|
||||||
.get_provider_config("default")
|
.get_provider_config("default")
|
||||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
.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、
|
// 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、
|
||||||
// /current 保存/展示的系统提示词与 LLM 实际接收的完全一致
|
// /current 保存/展示的系统提示词与 LLM 实际接收的完全一致
|
||||||
|
|||||||
@ -24,7 +24,9 @@ export function getGatewaySettings(): GatewaySettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildWsUrl(settings: GatewaySettings): string {
|
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)
|
// 远程访问时需要携带认证 token(浏览器原生 WebSocket 不支持自定义 header)
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user