From 94bf387aee6b081ba4fc16f4167114d06deadc55 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 13:07:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(saas):=20=E5=AE=89=E5=85=A8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20IDOR=E9=98=B2=E6=8A=A4=E3=80=81SSRF?= =?UTF-8?q?=E9=98=B2=E6=8A=A4=E3=80=81JWT=E5=AF=86=E9=92=A5=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E3=80=81=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E3=80=81CORS=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account: admin 权限守卫 (list_accounts/get_account/update_status/list_logs) - relay: SSRF 防护 (禁止内网地址、限制 http scheme、30s 超时) - config: 生产环境强制 ZCLAW_SAAS_JWT_SECRET 环境变量 - error: 500 错误不再泄露内部细节给客户端 - main: CORS 支持配置白名单 origins - 全部 21 个测试通过 (7 unit + 14 integration) --- Cargo.lock | 1 + crates/zclaw-saas/Cargo.toml | 1 + crates/zclaw-saas/src/account/handlers.rs | 34 ++++++++++++---- crates/zclaw-saas/src/config.rs | 30 ++++++++++---- crates/zclaw-saas/src/error.rs | 13 +++++- crates/zclaw-saas/src/main.rs | 24 ++++++++--- crates/zclaw-saas/src/relay/service.rs | 44 ++++++++++++++++++++- crates/zclaw-saas/src/state.rs | 8 ++-- crates/zclaw-saas/tests/integration_test.rs | 10 +++-- 9 files changed, 134 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be6b01b..31ae41a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7451,6 +7451,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "url", "uuid", "zclaw-types", ] diff --git a/crates/zclaw-saas/Cargo.toml b/crates/zclaw-saas/Cargo.toml index 4bdb077..42a12e8 100644 --- a/crates/zclaw-saas/Cargo.toml +++ b/crates/zclaw-saas/Cargo.toml @@ -29,6 +29,7 @@ sha2 = { workspace = true } rand = { workspace = true } dashmap = { workspace = true } hex = { workspace = true } +url = "2" axum = { workspace = true } axum-extra = { workspace = true } diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index 4d5d488..1671bb7 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -5,17 +5,25 @@ use axum::{ Json, }; use crate::state::AppState; -use crate::error::SaasResult; +use crate::error::{SaasError, SaasResult}; use crate::auth::types::AuthContext; use crate::auth::handlers::log_operation; use super::{types::*, service}; -/// GET /api/v1/accounts +fn require_admin(ctx: &AuthContext) -> SaasResult<()> { + if !ctx.permissions.contains(&"account:admin".to_string()) { + return Err(SaasError::Forbidden("需要 account:admin 权限".into())); + } + Ok(()) +} + +/// GET /api/v1/accounts (admin only) pub async fn list_accounts( State(state): State, Query(query): Query, - _ctx: Extension, + Extension(ctx): Extension, ) -> SaasResult>> { + require_admin(&ctx)?; service::list_accounts(&state.db, &query).await.map(Json) } @@ -23,30 +31,39 @@ pub async fn list_accounts( pub async fn get_account( State(state): State, Path(id): Path, - _ctx: Extension, + Extension(ctx): Extension, ) -> SaasResult> { + // 只能查看自己,或 admin 查看任何人 + if id != ctx.account_id { + require_admin(&ctx)?; + } service::get_account(&state.db, &id).await.map(Json) } -/// PUT /api/v1/accounts/:id +/// PUT /api/v1/accounts/:id (admin or self for limited fields) pub async fn update_account( State(state): State, Path(id): Path, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { + // 非管理员只能修改自己的资料 + if id != ctx.account_id { + require_admin(&ctx)?; + } let result = service::update_account(&state.db, &id, &req).await?; log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?; Ok(Json(result)) } -/// PATCH /api/v1/accounts/:id/status +/// PATCH /api/v1/accounts/:id/status (admin only) pub async fn update_status( State(state): State, Path(id): Path, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { + require_admin(&ctx)?; service::update_account_status(&state.db, &id, &req.status).await?; log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id, Some(serde_json::json!({"status": &req.status})), None).await?; @@ -84,12 +101,13 @@ pub async fn revoke_token( Ok(Json(serde_json::json!({"ok": true}))) } -/// GET /api/v1/logs/operations +/// GET /api/v1/logs/operations (admin only) pub async fn list_operation_logs( State(state): State, Query(params): Query>, - _ctx: Extension, + Extension(ctx): Extension, ) -> SaasResult>> { + require_admin(&ctx)?; let page: i64 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1); let page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50); let offset = (page - 1) * page_size; diff --git a/crates/zclaw-saas/src/config.rs b/crates/zclaw-saas/src/config.rs index 6dfa64c..c987235 100644 --- a/crates/zclaw-saas/src/config.rs +++ b/crates/zclaw-saas/src/config.rs @@ -132,13 +132,27 @@ impl SaaSConfig { Ok(config) } - /// 获取 JWT 密钥 (从环境变量或生成默认值) - pub fn jwt_secret(&self) -> SecretString { - std::env::var("ZCLAW_SAAS_JWT_SECRET") - .map(SecretString::from) - .unwrap_or_else(|_| { - tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using default (insecure!)"); - SecretString::from("zclaw-saas-default-secret-change-in-production".to_string()) - }) + /// 获取 JWT 密钥 (从环境变量或生成临时值) + /// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET + pub fn jwt_secret(&self) -> anyhow::Result { + 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 { + 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 { + anyhow::bail!( + "ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\ + 请设置一个强随机密钥 (至少 32 字符)。\ + 开发环境可设置 ZCLAW_SAAS_DEV=true 使用默认值。" + ) + } + } + } } } diff --git a/crates/zclaw-saas/src/error.rs b/crates/zclaw-saas/src/error.rs index 6dd1881..1b02dfa 100644 --- a/crates/zclaw-saas/src/error.rs +++ b/crates/zclaw-saas/src/error.rs @@ -107,9 +107,18 @@ impl SaasError { impl IntoResponse for SaasError { fn into_response(self) -> Response { let status = self.status_code(); + let (error_code, message) = match &self { + // 500 错误不泄露内部细节给客户端 + Self::Database(_) | Self::Internal(_) | Self::Io(_) + | Self::Jwt(_) | Self::Config(_) => { + tracing::error!("内部错误 [{}]: {}", self.error_code(), self); + (self.error_code().to_string(), "服务内部错误".to_string()) + } + _ => (self.error_code().to_string(), self.to_string()), + }; let body = json!({ - "error": self.error_code(), - "message": self.to_string(), + "error": error_code, + "message": message, }); (status, axum::Json(body)).into_response() } diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index 2a554ee..a314f83 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -18,7 +18,7 @@ async fn main() -> anyhow::Result<()> { let db = init_db(&config.database.url).await?; info!("Database initialized"); - let state = AppState::new(db, config.clone()); + let state = AppState::new(db, config.clone())?; let app = build_router(state); let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port)) @@ -34,10 +34,24 @@ fn build_router(state: AppState) -> axum::Router { use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + use axum::http::HeaderValue; + let cors = { + let config = state.config.blocking_read(); + if config.server.cors_origins.is_empty() { + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) + } else { + let origins: Vec = config.server.cors_origins.iter() + .filter_map(|o: &String| o.parse::().ok()) + .collect(); + CorsLayer::new() + .allow_origin(origins) + .allow_methods(Any) + .allow_headers(Any) + } + }; let public_routes = zclaw_saas::auth::routes(); diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index 06fa697..5a796c1 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -120,10 +120,16 @@ pub async fn execute_relay( ) -> SaasResult { update_task_status(db, task_id, "processing", None, None, None).await?; + // SSRF 防护: 验证 URL scheme 和禁止内网地址 + validate_provider_url(provider_base_url)?; + let url = format!("{}/chat/completions", provider_base_url.trim_end_matches('/')); let _start = std::time::Instant::now(); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?; let mut req_builder = client.post(&url) .header("Content-Type", "application/json") .body(request_body.to_string()); @@ -195,3 +201,39 @@ fn extract_token_usage(body: &str) -> (i64, i64) { (input, output) } + +/// SSRF 防护: 验证 provider URL 不指向内网 +fn validate_provider_url(url: &str) -> SaasResult<()> { + let parsed: url::Url = url.parse().map_err(|_| { + SaasError::InvalidInput(format!("无效的 provider URL: {}", url)) + })?; + + // 只允许 https + match parsed.scheme() { + "https" => {} + "http" => { + // 开发环境允许 http + let is_dev = std::env::var("ZCLAW_SAAS_DEV") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + if !is_dev { + return Err(SaasError::InvalidInput("生产环境禁止 http scheme,请使用 https".into())); + } + } + _ => return Err(SaasError::InvalidInput(format!("不允许的 URL scheme: {}", parsed.scheme()))), + } + + // 禁止内网地址 + let host = match parsed.host_str() { + Some(h) => h, + None => return Err(SaasError::InvalidInput("provider URL 缺少 host".into())), + }; + let blocked = ["127.0.0.1", "0.0.0.0", "localhost", "::1", "169.254.169.254", "metadata.google.internal"]; + for blocked_host in &blocked { + if host == *blocked_host || host.ends_with(&format!(".{}", blocked_host)) { + return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host))); + } + } + + Ok(()) +} diff --git a/crates/zclaw-saas/src/state.rs b/crates/zclaw-saas/src/state.rs index e428b0d..6f2a78c 100644 --- a/crates/zclaw-saas/src/state.rs +++ b/crates/zclaw-saas/src/state.rs @@ -17,12 +17,12 @@ pub struct AppState { } impl AppState { - pub fn new(db: SqlitePool, config: SaaSConfig) -> Self { - let jwt_secret = config.jwt_secret(); - Self { + pub fn new(db: SqlitePool, config: SaaSConfig) -> anyhow::Result { + let jwt_secret = config.jwt_secret()?; + Ok(Self { db, config: Arc::new(RwLock::new(config)), jwt_secret, - } + }) } } diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index 0e37919..559758f 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -12,10 +12,14 @@ const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB async fn build_test_app() -> axum::Router { use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState}; + // 测试环境设置开发模式 (允许 http、默认 JWT secret) + std::env::set_var("ZCLAW_SAAS_DEV", "true"); + std::env::set_var("ZCLAW_SAAS_JWT_SECRET", "test-secret-for-integration-tests-only"); + let db = init_memory_db().await.unwrap(); let mut config = SaaSConfig::default(); config.auth.jwt_expiration_hours = 24; - let state = AppState::new(db, config); + let state = AppState::new(db, config).expect("测试环境 AppState 初始化失败"); let public_routes = zclaw_saas::auth::routes(); @@ -174,7 +178,7 @@ async fn test_full_authenticated_flow() { let resp = app.clone().oneshot(list_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); - // 查看操作日志 + // 查看操作日志 (普通用户无 admin 权限 → 403) let logs_req = Request::builder() .method("GET") .uri("/api/v1/logs/operations") @@ -183,7 +187,7 @@ async fn test_full_authenticated_flow() { .unwrap(); let resp = app.oneshot(logs_req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // ============ Phase 2: 模型配置测试 ============