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.
This commit is contained in:
iven
2026-04-01 08:38:37 +08:00
parent 3b1a017761
commit e3b93ff96d
26 changed files with 597 additions and 220 deletions

View File

@@ -11,6 +11,39 @@ use crate::auth::handlers::{log_operation, check_permission};
use crate::common::PaginatedResponse;
use super::{types::*, service};
/// 验证 Provider base_url: 必须 HTTPS (开发环境允许 HTTP),不能指向本地/私有地址
fn validate_provider_base_url(url: &str) -> Result<(), String> {
if url.is_empty() {
return Err("base_url 不能为空".into());
}
if let Ok(parsed) = url::Url::parse(url) {
let scheme = parsed.scheme();
let is_dev = std::env::var("ZCLAW_SAAS_DEV").map(|v| v == "true").unwrap_or(false);
if scheme != "https" && !(is_dev && scheme == "http") {
return Err(format!("base_url 必须使用 HTTPS{}", if is_dev { "(开发环境允许 HTTP" } else { "" }));
}
if let Some(host) = parsed.host_str() {
let blocked = ["localhost", "127.0.0.1", "0.0.0.0", "metadata.google.internal"];
if blocked.contains(&host) {
return Err("base_url 不能指向本地或内部地址".into());
}
for prefix in &["10.", "172.16.", "192.168.", "169.254."] {
if host.starts_with(prefix) {
return Err("base_url 不能指向私有 IP 地址".into());
}
}
for suffix in &[".localhost", ".internal", ".local"] {
if host.ends_with(suffix) {
return Err(format!("base_url 域名不能以 {} 结尾", suffix));
}
}
}
Ok(())
} else {
Err("base_url 格式无效".into())
}
}
// ============ Providers ============
/// GET /api/v1/providers?enabled=true&page=1&page_size=20
@@ -41,6 +74,7 @@ pub async fn create_provider(
Json(req): Json<CreateProviderRequest>,
) -> SaasResult<(StatusCode, Json<ProviderInfo>)> {
check_permission(&ctx, "provider:manage")?;
validate_provider_base_url(&req.base_url).map_err(|e| SaasError::InvalidInput(e))?;
let config = state.config.read().await;
let enc_key = config.api_key_encryption_key()
.map_err(|e| SaasError::Internal(e.to_string()))?;
@@ -59,6 +93,9 @@ pub async fn update_provider(
Json(req): Json<UpdateProviderRequest>,
) -> SaasResult<Json<ProviderInfo>> {
check_permission(&ctx, "provider:manage")?;
if let Some(ref base_url) = req.base_url {
validate_provider_base_url(base_url).map_err(|e| SaasError::InvalidInput(e))?;
}
let config = state.config.read().await;
let enc_key = config.api_key_encryption_key()
.map_err(|e| SaasError::Internal(e.to_string()))?;