fix(saas): 安全修复 — IDOR防护、SSRF防护、JWT密钥强制、错误信息脱敏、CORS配置化

- 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)
This commit is contained in:
iven
2026-03-27 13:07:20 +08:00
parent 00a08c9f9b
commit 94bf387aee
9 changed files with 134 additions and 31 deletions

1
Cargo.lock generated
View File

@@ -7451,6 +7451,7 @@ dependencies = [
"tower-http 0.5.2",
"tracing",
"tracing-subscriber",
"url",
"uuid",
"zclaw-types",
]

View File

@@ -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 }

View File

@@ -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<AppState>,
Query(query): Query<ListAccountsQuery>,
_ctx: Extension<AuthContext>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
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<AppState>,
Path(id): Path<String>,
_ctx: Extension<AuthContext>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<serde_json::Value>> {
// 只能查看自己,或 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<AppState>,
Path(id): Path<String>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<UpdateAccountRequest>,
) -> SaasResult<Json<serde_json::Value>> {
// 非管理员只能修改自己的资料
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<AppState>,
Path(id): Path<String>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<UpdateStatusRequest>,
) -> SaasResult<Json<serde_json::Value>> {
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<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
_ctx: Extension<AuthContext>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<serde_json::Value>>> {
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;

View File

@@ -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<SecretString> {
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 使用默认值。"
)
}
}
}
}
}

View File

@@ -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()
}

View File

@@ -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<HeaderValue> = config.server.cors_origins.iter()
.filter_map(|o: &String| o.parse::<HeaderValue>().ok())
.collect();
CorsLayer::new()
.allow_origin(origins)
.allow_methods(Any)
.allow_headers(Any)
}
};
let public_routes = zclaw_saas::auth::routes();

View File

@@ -120,10 +120,16 @@ pub async fn execute_relay(
) -> SaasResult<RelayResponse> {
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(())
}

View File

@@ -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<Self> {
let jwt_secret = config.jwt_secret()?;
Ok(Self {
db,
config: Arc::new(RwLock::new(config)),
jwt_secret,
}
})
}
}

View File

@@ -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: 模型配置测试 ============