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:
@@ -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()))?;
|
||||
|
||||
Reference in New Issue
Block a user