Files
zclaw_openfang/crates/zclaw-saas/src/config.rs
iven e3b93ff96d fix(security): implement all 15 security fixes from penetration test V1
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.
2026-04-01 08:38:37 +08:00

362 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
}