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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -7451,6 +7451,7 @@ dependencies = [
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"zclaw-types",
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 使用默认值。"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 模型配置测试 ============
|
||||
|
||||
Reference in New Issue
Block a user