Security audit (2026-03-31): 5 HIGH + 10 MEDIUM issues, all fixed. HIGH: - H1: JWT password_version mechanism (pwv in Claims, middleware verification, auto-increment on password change) - H2: Docker saas port bound to 127.0.0.1 - H3: TOTP encryption key decoupled from JWT secret (production bailout) - H4+H5: Tauri CSP hardened (removed unsafe-inline, restricted connect-src) MEDIUM: - M1: Persistent rate limiting (PostgreSQL rate_limit_events table) - M2: Account lockout (5 failures -> 15min lock) - M3: RFC 5322 email validation with regex - M4: Device registration typed struct with length limits - M5: Provider URL validation on create/update (SSRF prevention) - M6: Legacy TOTP secret migration (fixed nonce -> random nonce) - M7: Legacy frontend crypto migration (static salt -> random salt) - M8+M9: Admin frontend: removed JS token storage, HttpOnly cookie only - M10: Pipeline debug log sanitization (keys only, 100-char truncation) Also: fixed CLAUDE.md Section 12 (was corrupted), added title.rs middleware skeleton, fixed RegisterDeviceRequest visibility.
362 lines
12 KiB
Rust
362 lines
12 KiB
Rust
//! SaaS 服务器配置
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::PathBuf;
|
||
use secrecy::SecretString;
|
||
|
||
/// SaaS 服务器完整配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SaaSConfig {
|
||
pub server: ServerConfig,
|
||
pub database: DatabaseConfig,
|
||
pub auth: AuthConfig,
|
||
pub relay: RelayConfig,
|
||
#[serde(default)]
|
||
pub rate_limit: RateLimitConfig,
|
||
#[serde(default)]
|
||
pub scheduler: SchedulerConfig,
|
||
}
|
||
|
||
/// Scheduler 定时任务配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SchedulerConfig {
|
||
#[serde(default)]
|
||
pub jobs: Vec<JobConfig>,
|
||
}
|
||
|
||
/// 单个定时任务配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct JobConfig {
|
||
pub name: String,
|
||
/// 间隔时间,支持 "5m", "1h", "24h", "30s" 格式
|
||
pub interval: String,
|
||
/// 对应的 Worker 名称
|
||
pub task: String,
|
||
/// 传递给 Worker 的参数(JSON 格式)
|
||
#[serde(default)]
|
||
pub args: Option<serde_json::Value>,
|
||
/// 是否在启动时立即执行
|
||
#[serde(default)]
|
||
pub run_on_start: bool,
|
||
}
|
||
|
||
impl Default for SchedulerConfig {
|
||
fn default() -> Self {
|
||
Self { jobs: Vec::new() }
|
||
}
|
||
}
|
||
|
||
/// 服务器配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ServerConfig {
|
||
#[serde(default = "default_host")]
|
||
pub host: String,
|
||
#[serde(default = "default_port")]
|
||
pub port: u16,
|
||
#[serde(default)]
|
||
pub cors_origins: Vec<String>,
|
||
/// 可信反向代理 IP 列表。仅对来自这些 IP 的请求解析 X-Forwarded-For 头。
|
||
/// 生产环境应为 Nginx/Caddy 的实际 IP,如 ["127.0.0.1", "10.0.0.1"]
|
||
#[serde(default)]
|
||
pub trusted_proxies: Vec<String>,
|
||
}
|
||
|
||
/// 数据库配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DatabaseConfig {
|
||
#[serde(default = "default_db_url")]
|
||
pub url: String,
|
||
}
|
||
|
||
/// 认证配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AuthConfig {
|
||
#[serde(default = "default_jwt_hours")]
|
||
pub jwt_expiration_hours: i64,
|
||
#[serde(default = "default_totp_issuer")]
|
||
pub totp_issuer: String,
|
||
/// Refresh Token 有效期 (小时), 默认 168 小时 = 7 天
|
||
#[serde(default = "default_refresh_hours")]
|
||
pub refresh_token_hours: i64,
|
||
}
|
||
|
||
/// 中转服务配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct RelayConfig {
|
||
#[serde(default = "default_max_queue")]
|
||
pub max_queue_size: usize,
|
||
/// 每个 Provider 最大并发请求数 (预留,当前由 max_queue_size 控制)
|
||
#[serde(default = "default_max_concurrent")]
|
||
pub max_concurrent_per_provider: usize,
|
||
/// 批量窗口间隔 (预留,用于请求合并优化)
|
||
#[serde(default = "default_batch_window")]
|
||
pub batch_window_ms: u64,
|
||
#[serde(default = "default_retry_delay")]
|
||
pub retry_delay_ms: u64,
|
||
#[serde(default = "default_max_attempts")]
|
||
pub max_attempts: u32,
|
||
}
|
||
|
||
fn default_host() -> String { "0.0.0.0".into() }
|
||
fn default_port() -> u16 { 8080 }
|
||
fn default_db_url() -> String { "postgres://localhost:5432/zclaw".into() }
|
||
fn default_jwt_hours() -> i64 { 24 }
|
||
fn default_totp_issuer() -> String { "ZCLAW SaaS".into() }
|
||
fn default_refresh_hours() -> i64 { 168 }
|
||
fn default_max_queue() -> usize { 1000 }
|
||
fn default_max_concurrent() -> usize { 5 }
|
||
fn default_batch_window() -> u64 { 50 }
|
||
fn default_retry_delay() -> u64 { 1000 }
|
||
fn default_max_attempts() -> u32 { 3 }
|
||
|
||
/// 速率限制配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct RateLimitConfig {
|
||
/// 每分钟最大请求数 (滑动窗口)
|
||
#[serde(default = "default_rpm")]
|
||
pub requests_per_minute: u32,
|
||
/// 突发允许的额外请求数
|
||
#[serde(default = "default_burst")]
|
||
pub burst: u32,
|
||
}
|
||
|
||
fn default_rpm() -> u32 { 60 }
|
||
fn default_burst() -> u32 { 10 }
|
||
|
||
impl Default for RateLimitConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
requests_per_minute: default_rpm(),
|
||
burst: default_burst(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for SaaSConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
server: ServerConfig::default(),
|
||
database: DatabaseConfig::default(),
|
||
auth: AuthConfig::default(),
|
||
relay: RelayConfig::default(),
|
||
rate_limit: RateLimitConfig::default(),
|
||
scheduler: SchedulerConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ServerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
host: default_host(),
|
||
port: default_port(),
|
||
cors_origins: Vec::new(),
|
||
trusted_proxies: vec![],
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for DatabaseConfig {
|
||
fn default() -> Self {
|
||
Self { url: default_db_url() }
|
||
}
|
||
}
|
||
|
||
impl Default for AuthConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
jwt_expiration_hours: default_jwt_hours(),
|
||
totp_issuer: default_totp_issuer(),
|
||
refresh_token_hours: default_refresh_hours(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for RelayConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_queue_size: default_max_queue(),
|
||
max_concurrent_per_provider: default_max_concurrent(),
|
||
batch_window_ms: default_batch_window(),
|
||
retry_delay_ms: default_retry_delay(),
|
||
max_attempts: default_max_attempts(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl SaaSConfig {
|
||
/// 加载配置文件,优先级: ZCLAW_SAAS_CONFIG > ZCLAW_ENV > ./saas-config.toml
|
||
///
|
||
/// ZCLAW_ENV 环境选择:
|
||
/// development → config/saas-development.toml
|
||
/// production → config/saas-production.toml
|
||
/// test → config/saas-test.toml
|
||
///
|
||
/// ZCLAW_SAAS_CONFIG 指定精确路径(最高优先级)
|
||
pub fn load() -> anyhow::Result<Self> {
|
||
let config_path = if let Ok(path) = std::env::var("ZCLAW_SAAS_CONFIG") {
|
||
PathBuf::from(path)
|
||
} else if let Ok(env) = std::env::var("ZCLAW_ENV") {
|
||
let filename = format!("config/saas-{}.toml", env);
|
||
let path = PathBuf::from(&filename);
|
||
if !path.exists() {
|
||
anyhow::bail!(
|
||
"ZCLAW_ENV={} 指定的配置文件 {} 不存在",
|
||
env, filename
|
||
);
|
||
}
|
||
tracing::info!("Loading config for environment: {}", env);
|
||
path
|
||
} else {
|
||
PathBuf::from("saas-config.toml")
|
||
};
|
||
|
||
let mut config = if config_path.exists() {
|
||
let content = std::fs::read_to_string(&config_path)?;
|
||
let interpolated = interpolate_env_vars(&content);
|
||
toml::from_str(&interpolated)?
|
||
} else {
|
||
tracing::warn!("Config file {:?} not found, using defaults", config_path);
|
||
SaaSConfig::default()
|
||
};
|
||
|
||
// 环境变量覆盖数据库 URL (避免在配置文件中存储密码)
|
||
if let Ok(db_url) = std::env::var("ZCLAW_DATABASE_URL") {
|
||
config.database.url = db_url;
|
||
}
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// 获取 JWT 密钥 (从环境变量或生成临时值)
|
||
/// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET
|
||
pub fn jwt_secret(&self) -> anyhow::Result<SecretString> {
|
||
match std::env::var("ZCLAW_SAAS_JWT_SECRET") {
|
||
Ok(secret) => Ok(SecretString::from(secret)),
|
||
Err(_) => {
|
||
// 开发 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()))
|
||
}
|
||
#[cfg(not(debug_assertions))]
|
||
{
|
||
anyhow::bail!(
|
||
"ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\
|
||
请设置一个强随机密钥 (至少 32 字符)。"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 获取 API Key 加密密钥 (复用 TOTP 加密密钥)
|
||
pub fn api_key_encryption_key(&self) -> anyhow::Result<[u8; 32]> {
|
||
self.totp_encryption_key()
|
||
}
|
||
|
||
/// 获取 TOTP 加密密钥 (AES-256-GCM, 32 字节)
|
||
/// 从 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量加载 (hex 编码的 64 字符)
|
||
/// 开发环境使用默认值 (不安全)
|
||
pub fn totp_encryption_key(&self) -> anyhow::Result<[u8; 32]> {
|
||
match std::env::var("ZCLAW_TOTP_ENCRYPTION_KEY") {
|
||
Ok(hex_key) => {
|
||
if hex_key.len() != 64 {
|
||
anyhow::bail!("ZCLAW_TOTP_ENCRYPTION_KEY 必须是 64 个十六进制字符 (32 字节)");
|
||
}
|
||
let mut key = [0u8; 32];
|
||
for i in 0..32 {
|
||
key[i] = u8::from_str_radix(&hex_key[i*2..i*2+2], 16)
|
||
.map_err(|_| anyhow::anyhow!("ZCLAW_TOTP_ENCRYPTION_KEY 包含无效的十六进制字符"))?;
|
||
}
|
||
Ok(key)
|
||
}
|
||
Err(_) => {
|
||
// 开发环境: 仅在 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)
|
||
}
|
||
#[cfg(not(debug_assertions))]
|
||
{
|
||
anyhow::bail!(
|
||
"生产环境必须设置 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量 (64 个十六进制字符, 32 字节)"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 替换 TOML 配置文件中的 `${ENV_VAR}` 模式为环境变量值
|
||
/// 未设置的环境变量保留原文,后续数据库连接或 JWT 初始化时会报明确错误
|
||
fn interpolate_env_vars(content: &str) -> String {
|
||
let mut result = String::with_capacity(content.len());
|
||
let bytes = content.as_bytes();
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
|
||
let start = i + 2;
|
||
let mut end = start;
|
||
while end < bytes.len()
|
||
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
|
||
{
|
||
end += 1;
|
||
}
|
||
if end < bytes.len() && bytes[end] == b'}' {
|
||
let var_name = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
|
||
match std::env::var(var_name) {
|
||
Ok(val) => {
|
||
tracing::debug!("Config: ${{{}}} → resolved ({} bytes)", var_name, val.len());
|
||
result.push_str(&val);
|
||
}
|
||
Err(_) => {
|
||
tracing::warn!("Config: ${{{}}} not set, keeping placeholder", var_name);
|
||
result.push_str(&format!("${{{}}}", var_name));
|
||
}
|
||
}
|
||
i = end + 1;
|
||
} else {
|
||
result.push(bytes[i] as char);
|
||
i += 1;
|
||
}
|
||
} else {
|
||
result.push(bytes[i] as char);
|
||
i += 1;
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_interpolate_env_vars_resolves() {
|
||
std::env::set_var("TEST_ZCLAW_DB_PW", "mypassword");
|
||
let input = "url = \"postgres://user:${TEST_ZCLAW_DB_PW}@localhost/db\"";
|
||
let result = interpolate_env_vars(input);
|
||
assert_eq!(result, "url = \"postgres://user:mypassword@localhost/db\"");
|
||
std::env::remove_var("TEST_ZCLAW_DB_PW");
|
||
}
|
||
|
||
#[test]
|
||
fn test_interpolate_env_vars_missing_keeps_placeholder() {
|
||
let input = "url = \"postgres://user:${NONEXISTENT_VAR_12345}@localhost/db\"";
|
||
let result = interpolate_env_vars(input);
|
||
assert_eq!(result, "url = \"postgres://user:${NONEXISTENT_VAR_12345}@localhost/db\"");
|
||
}
|
||
|
||
#[test]
|
||
fn test_interpolate_env_vars_no_placeholders() {
|
||
let input = "host = \"0.0.0.0\"\nport = 8080";
|
||
let result = interpolate_env_vars(input);
|
||
assert_eq!(result, input);
|
||
}
|
||
}
|