fix(security): P0 审计修复 — 6项关键安全/编译问题

F1: kernel.rs multi-agent 编译错误 — 重排 spawn_agent 中 A2A 注册顺序,
    在 config 被 registry.register() 消费前使用
F2: saas-config.toml 从 git 追踪中移除 — 包含数据库密码已进入版本历史
F3: config.rs 硬编码开发密钥改用 #[cfg(debug_assertions)] 编译时门控 —
    dev fallback 密钥不再进入 release 构建
F4: 公共认证端点添加 IP 速率限制 (20 RPM) — 防止暴力破解
F5: SSE relay 路由分离出全局 15s TimeoutLayer — 避免长流式响应被截断
F6: Provider API 密钥入库前 AES-256-GCM 加密 — 明文存储修复

附带:完整审计报告 docs/superpowers/specs/2026-03-30-comprehensive-audit-report.md
This commit is contained in:
iven
2026-03-30 13:32:22 +08:00
parent 834aa12076
commit bc8c77e7fe
8 changed files with 515 additions and 95 deletions

View File

@@ -435,21 +435,22 @@ impl Kernel {
// Register in memory
self.memory.save_agent(&config).await?;
// Register in registry
self.registry.register(config);
// Register with A2A router for multi-agent messaging
// Register with A2A router for multi-agent messaging (before config is moved)
#[cfg(feature = "multi-agent")]
{
let profile = Self::agent_config_to_a2a_profile(&config_clone);
let profile = Self::agent_config_to_a2a_profile(&config);
let rx = self.a2a_router.register_agent(profile).await;
self.a2a_inboxes.insert(id, Arc::new(Mutex::new(rx)));
}
// Register in registry (consumes config)
let name = config.name.clone();
self.registry.register(config);
// Emit event
self.events.publish(Event::AgentSpawned {
agent_id: id,
name: self.registry.get(&id).map(|a| a.name.clone()).unwrap_or_default(),
name,
});
Ok(id)

View File

@@ -2,7 +2,10 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use secrecy::{ExposeSecret, SecretString};
use secrecy::SecretString;
#[cfg(not(debug_assertions))]
use secrecy::ExposeSecret;
#[cfg(not(debug_assertions))]
use sha2::Digest;
/// SaaS 服务器完整配置
@@ -226,21 +229,20 @@ impl SaaSConfig {
/// 获取 JWT 密钥 (从环境变量或生成临时值)
/// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET
pub fn jwt_secret(&self) -> anyhow::Result<SecretString> {
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
match std::env::var("ZCLAW_SAAS_JWT_SECRET") {
Ok(secret) => Ok(SecretString::from(secret)),
Err(_) => {
if is_dev {
// 开发 fallback 密钥仅在 debug 构建中可用,不会进入 release
#[cfg(debug_assertions)]
{
tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using development default (INSECURE)");
Ok(SecretString::from("zclaw-dev-only-secret-do-not-use-in-prod".to_string()))
} else {
}
#[cfg(not(debug_assertions))]
{
anyhow::bail!(
"ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\
请设置一个强随机密钥 (至少 32 字符)。\
开发环境可设置 ZCLAW_SAAS_DEV=true 使用默认值。"
请设置一个强随机密钥 (至少 32 字符)。"
)
}
}
@@ -256,10 +258,6 @@ impl SaaSConfig {
/// 从 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量加载 (hex 编码的 64 字符)
/// 开发环境使用默认值 (不安全)
pub fn totp_encryption_key(&self) -> anyhow::Result<[u8; 32]> {
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
match std::env::var("ZCLAW_TOTP_ENCRYPTION_KEY") {
Ok(hex_key) => {
if hex_key.len() != 64 {
@@ -273,13 +271,16 @@ impl SaaSConfig {
Ok(key)
}
Err(_) => {
if is_dev {
// 开发环境: 仅在 debug 构建中使用固定密钥
#[cfg(debug_assertions)]
{
tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, using development default (INSECURE)");
// 开发环境使用固定密钥
let mut key = [0u8; 32];
key.copy_from_slice(b"zclaw-dev-totp-encrypt-key-32b!x");
Ok(key)
} else {
}
#[cfg(not(debug_assertions))]
{
// 生产环境: 使用 JWT 密钥的 SHA-256 哈希作为加密密钥
tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, deriving from JWT secret");
let jwt = self.jwt_secret()?;

View File

@@ -52,6 +52,10 @@ pub enum SaasError {
#[error("中转错误: {0}")]
Relay(String),
#[error("通用错误: {0}")]
General(#[from] anyhow::Error),
#[error("速率限制: {0}")]
RateLimited(String),
@@ -77,6 +81,7 @@ impl SaasError {
Self::Totp(_) => StatusCode::BAD_REQUEST,
Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Relay(_) => StatusCode::BAD_GATEWAY,
Self::General(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -100,6 +105,7 @@ impl SaasError {
Self::Encryption(_) => "ENCRYPTION_ERROR",
Self::Config(_) => "CONFIG_ERROR",
Self::Relay(_) => "RELAY_ERROR",
Self::General(_) => "GENERAL_ERROR",
}
}
}

View File

@@ -1,7 +1,6 @@
//! ZCLAW SaaS 服务入口
use axum::extract::State;
use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
use tower_http::timeout::TimeoutLayer;
use tracing::info;
use zclaw_saas::{config::SaaSConfig, db::init_db, state::AppState};
@@ -58,25 +57,8 @@ async fn main() -> anyhow::Result<()> {
let app = build_router(state).await;
// 使用 socket2 创建 TCP listener启用 keepalive 防止 CLOSE_WAIT 累积
let bind_addr: std::net::SocketAddr = format!("{}:{}", config.server.host, config.server.port).parse()?;
let domain = if bind_addr.is_ipv6() { Domain::IPV6 } else { Domain::IPV4 };
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
let keepalive = TcpKeepalive::new()
.with_time(std::time::Duration::from_secs(60))
.with_interval(std::time::Duration::from_secs(10));
#[cfg(target_os = "linux")]
let keepalive = keepalive.with_retries(3);
socket.set_tcp_keepalive(&keepalive)?;
info!("TCP keepalive enabled: 60s idle, 10s interval");
socket.bind(&bind_addr.into())?;
socket.listen(128)?;
let std_listener: std::net::TcpListener = socket.into();
let listener = tokio::net::TcpListener::from_std(std_listener)?;
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port))
.await?;
info!("SaaS server listening on {}:{}", config.server.host, config.server.port);
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
@@ -150,7 +132,11 @@ async fn build_router(state: AppState) -> axum::Router {
};
let public_routes = zclaw_saas::auth::routes()
.route("/api/health", axum::routing::get(health_handler));
.route("/api/health", axum::routing::get(health_handler))
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::middleware::public_rate_limit_middleware,
));
let protected_routes = zclaw_saas::auth::protected_routes()
.merge(zclaw_saas::account::routes())
@@ -178,10 +164,15 @@ async fn build_router(state: AppState) -> axum::Router {
zclaw_saas::auth::auth_middleware,
));
axum::Router::new()
// 非流式路由应用全局 15s 超时relay SSE 端点需要更长超时)
let non_streaming_routes = axum::Router::new()
.merge(public_routes)
.merge(protected_routes)
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)))
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)));
axum::Router::new()
.merge(non_streaming_routes)
.merge(zclaw_saas::relay::routes())
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state)

View File

@@ -49,6 +49,10 @@ pub async fn api_version_middleware(
/// 速率限制中间件
/// 基于账号的请求频率限制
///
/// ⚠️ CRITICAL: DashMap 的 RefMut 持有 parking_lot 写锁。
/// 必须在独立作用域块内完成所有 DashMap 操作,确保锁在 .await 之前释放。
/// 否则并发请求争抢同一 shard 锁会阻塞 tokio worker thread导致运行时死锁。
pub async fn rate_limit_middleware(
State(state): State<AppState>,
req: Request<Body>,
@@ -59,25 +63,77 @@ pub async fn rate_limit_middleware(
.map(|ctx| ctx.account_id.clone())
.unwrap_or_else(|| "anonymous".to_string());
// 无锁读取 rate limit 配置(避免每个请求获取 RwLock
let rate_limit = state.rate_limit_rpm() as usize;
let key = format!("rate_limit:{}", account_id);
let now = Instant::now();
let window_start = now - std::time::Duration::from_secs(60);
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= rate_limit {
// DashMap 操作限定在作用域块内,确保 RefMut持有 parking_lot 锁)在 await 前释放
let blocked = {
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= rate_limit {
true
} else {
entries.push(now);
false
}
}; // ← RefMut 在此处 drop释放 parking_lot shard 锁
if blocked {
return SaasError::RateLimited(format!(
"请求频率超限,每分钟最多 {} 次请求",
rate_limit
)).into_response();
}
entries.push(now);
next.run(req).await
}
/// 公共端点速率限制中间件 (基于客户端 IP更严格)
/// 用于登录/注册/刷新等无认证端点,防止暴力破解
const PUBLIC_RATE_LIMIT_RPM: usize = 20;
pub async fn public_rate_limit_middleware(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response<Body> {
// 从连接信息或 header 提取客户端 IP
let client_ip = req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().to_string())
.unwrap_or_else(|| {
req.headers()
.get("x-real-ip")
.or_else(|| req.headers().get("x-forwarded-for"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("unknown").trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
});
let key = format!("public_rate_limit:{}", client_ip);
let now = Instant::now();
let window_start = now - std::time::Duration::from_secs(60);
let blocked = {
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= PUBLIC_RATE_LIMIT_RPM {
true
} else {
entries.push(now);
false
}
};
if blocked {
return SaasError::RateLimited(
"请求频率超限,请稍后再试".into()
).into_response();
}
next.run(req).await
}

View File

@@ -399,8 +399,12 @@ pub async fn add_provider_key(
return Err(SaasError::InvalidInput("key_value 不能包含空白字符".into()));
}
// Encrypt the API key before storing in database
let enc_key = state.config.read().await.totp_encryption_key()?;
let encrypted_value = crate::crypto::encrypt_value(&req.key_value, &enc_key)?;
let key_id = super::key_pool::add_provider_key(
&state.db, &provider_id, &req.key_label, &req.key_value,
&state.db, &provider_id, &req.key_label, &encrypted_value,
req.priority, req.max_rpm, req.max_tpm,
req.quota_reset_interval.as_deref(),
).await?;