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",
|
"tower-http 0.5.2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zclaw-types",
|
"zclaw-types",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ sha2 = { workspace = true }
|
|||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
axum-extra = { workspace = true }
|
axum-extra = { workspace = true }
|
||||||
|
|||||||
@@ -5,17 +5,25 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::error::SaasResult;
|
use crate::error::{SaasError, SaasResult};
|
||||||
use crate::auth::types::AuthContext;
|
use crate::auth::types::AuthContext;
|
||||||
use crate::auth::handlers::log_operation;
|
use crate::auth::handlers::log_operation;
|
||||||
use super::{types::*, service};
|
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(
|
pub async fn list_accounts(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<ListAccountsQuery>,
|
Query(query): Query<ListAccountsQuery>,
|
||||||
_ctx: Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
|
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
|
||||||
|
require_admin(&ctx)?;
|
||||||
service::list_accounts(&state.db, &query).await.map(Json)
|
service::list_accounts(&state.db, &query).await.map(Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,30 +31,39 @@ pub async fn list_accounts(
|
|||||||
pub async fn get_account(
|
pub async fn get_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
_ctx: Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
// 只能查看自己,或 admin 查看任何人
|
||||||
|
if id != ctx.account_id {
|
||||||
|
require_admin(&ctx)?;
|
||||||
|
}
|
||||||
service::get_account(&state.db, &id).await.map(Json)
|
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(
|
pub async fn update_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
Json(req): Json<UpdateAccountRequest>,
|
Json(req): Json<UpdateAccountRequest>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
// 非管理员只能修改自己的资料
|
||||||
|
if id != ctx.account_id {
|
||||||
|
require_admin(&ctx)?;
|
||||||
|
}
|
||||||
let result = service::update_account(&state.db, &id, &req).await?;
|
let result = service::update_account(&state.db, &id, &req).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?;
|
log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, None).await?;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PATCH /api/v1/accounts/:id/status
|
/// PATCH /api/v1/accounts/:id/status (admin only)
|
||||||
pub async fn update_status(
|
pub async fn update_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
Json(req): Json<UpdateStatusRequest>,
|
Json(req): Json<UpdateStatusRequest>,
|
||||||
) -> SaasResult<Json<serde_json::Value>> {
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
require_admin(&ctx)?;
|
||||||
service::update_account_status(&state.db, &id, &req.status).await?;
|
service::update_account_status(&state.db, &id, &req.status).await?;
|
||||||
log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id,
|
log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id,
|
||||||
Some(serde_json::json!({"status": &req.status})), None).await?;
|
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})))
|
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(
|
pub async fn list_operation_logs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
_ctx: Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
) -> 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: 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 page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50);
|
||||||
let offset = (page - 1) * page_size;
|
let offset = (page - 1) * page_size;
|
||||||
|
|||||||
@@ -132,13 +132,27 @@ impl SaaSConfig {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 JWT 密钥 (从环境变量或生成默认值)
|
/// 获取 JWT 密钥 (从环境变量或生成临时值)
|
||||||
pub fn jwt_secret(&self) -> SecretString {
|
/// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET
|
||||||
std::env::var("ZCLAW_SAAS_JWT_SECRET")
|
pub fn jwt_secret(&self) -> anyhow::Result<SecretString> {
|
||||||
.map(SecretString::from)
|
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
|
||||||
.unwrap_or_else(|_| {
|
.map(|v| v == "true" || v == "1")
|
||||||
tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using default (insecure!)");
|
.unwrap_or(false);
|
||||||
SecretString::from("zclaw-saas-default-secret-change-in-production".to_string())
|
|
||||||
})
|
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 {
|
impl IntoResponse for SaasError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = self.status_code();
|
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!({
|
let body = json!({
|
||||||
"error": self.error_code(),
|
"error": error_code,
|
||||||
"message": self.to_string(),
|
"message": message,
|
||||||
});
|
});
|
||||||
(status, axum::Json(body)).into_response()
|
(status, axum::Json(body)).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let db = init_db(&config.database.url).await?;
|
let db = init_db(&config.database.url).await?;
|
||||||
info!("Database initialized");
|
info!("Database initialized");
|
||||||
|
|
||||||
let state = AppState::new(db, config.clone());
|
let state = AppState::new(db, config.clone())?;
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port))
|
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::cors::{Any, CorsLayer};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
use axum::http::HeaderValue;
|
||||||
.allow_origin(Any)
|
let cors = {
|
||||||
.allow_methods(Any)
|
let config = state.config.blocking_read();
|
||||||
.allow_headers(Any);
|
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();
|
let public_routes = zclaw_saas::auth::routes();
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,16 @@ pub async fn execute_relay(
|
|||||||
) -> SaasResult<RelayResponse> {
|
) -> SaasResult<RelayResponse> {
|
||||||
update_task_status(db, task_id, "processing", None, None, None).await?;
|
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 url = format!("{}/chat/completions", provider_base_url.trim_end_matches('/'));
|
||||||
let _start = std::time::Instant::now();
|
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)
|
let mut req_builder = client.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(request_body.to_string());
|
.body(request_body.to_string());
|
||||||
@@ -195,3 +201,39 @@ fn extract_token_usage(body: &str) -> (i64, i64) {
|
|||||||
|
|
||||||
(input, output)
|
(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 {
|
impl AppState {
|
||||||
pub fn new(db: SqlitePool, config: SaaSConfig) -> Self {
|
pub fn new(db: SqlitePool, config: SaaSConfig) -> anyhow::Result<Self> {
|
||||||
let jwt_secret = config.jwt_secret();
|
let jwt_secret = config.jwt_secret()?;
|
||||||
Self {
|
Ok(Self {
|
||||||
db,
|
db,
|
||||||
config: Arc::new(RwLock::new(config)),
|
config: Arc::new(RwLock::new(config)),
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB
|
|||||||
async fn build_test_app() -> axum::Router {
|
async fn build_test_app() -> axum::Router {
|
||||||
use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState};
|
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 db = init_memory_db().await.unwrap();
|
||||||
let mut config = SaaSConfig::default();
|
let mut config = SaaSConfig::default();
|
||||||
config.auth.jwt_expiration_hours = 24;
|
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();
|
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();
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
// 查看操作日志
|
// 查看操作日志 (普通用户无 admin 权限 → 403)
|
||||||
let logs_req = Request::builder()
|
let logs_req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/v1/logs/operations")
|
.uri("/api/v1/logs/operations")
|
||||||
@@ -183,7 +187,7 @@ async fn test_full_authenticated_flow() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let resp = app.oneshot(logs_req).await.unwrap();
|
let resp = app.oneshot(logs_req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Phase 2: 模型配置测试 ============
|
// ============ Phase 2: 模型配置测试 ============
|
||||||
|
|||||||
Reference in New Issue
Block a user