fix(billing): resolve all audit findings — CSRF, float precision, TOCTOU, error sanitization
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- Add CSRF token protection for mock payment (SHA256 + constant-time verify)
- Replace f64 currency conversion with pure integer string parsing (parse_yuan_to_cents)
- Move subscription check inside transaction to prevent TOCTOU race
- Rewrite increment_usage to use atomic SQL (account_id+period_start WHERE)
- Add trade_no format validation in payment callback
- Sanitize error messages to prevent sensitive data leakage
- Use i32::try_from for WeChat amount conversion (prevent truncation)
- Replace window.__ZCLAW_STATS_SYNC_INTERVAL__ with useRef pattern
- Replace eprintln/println with tracing macros in lifecycle
- Remove unused variable in scheduler
- Remove duplicate sha2 and unused hmac from Cargo.toml
This commit is contained in:
iven
2026-04-02 20:04:43 +08:00
parent 8898bb399e
commit da438ad868
7 changed files with 127 additions and 34 deletions

View File

@@ -29,7 +29,6 @@ sqlx = { workspace = true }
pgvector = { version = "0.4", features = ["sqlx"] }
reqwest = { workspace = true }
secrecy = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
dashmap = { workspace = true }
hex = { workspace = true }
@@ -49,6 +48,7 @@ urlencoding = "2"
data-encoding = "2"
regex = { workspace = true }
aes-gcm = { workspace = true }
sha2 = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }

View File

@@ -2,9 +2,11 @@
use axum::{
extract::{Extension, Form, Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use sha2::Sha256;
use crate::auth::types::AuthContext;
use crate::error::{SaasError, SaasResult};
@@ -78,7 +80,7 @@ pub async fn increment_usage_dimension(
// 验证维度白名单
if !["hand_executions", "pipeline_runs", "relay_requests"].contains(&req.dimension.as_str()) {
return Err(SaasError::InvalidInput(
format!("无效的用量维度: {},支持: hand_executions / pipeline_runs / relay_requests", req.dimension)
"无效的用量维度,支持: hand_executions / pipeline_runs / relay_requests".into()
));
}
@@ -157,6 +159,12 @@ pub async fn payment_callback(
SaasError::InvalidInput("回调缺少交易号".into())
})?;
// 验证 trade_no 格式(防伪造)
if !trade_no.starts_with("ZCLAW-") || trade_no.len() > 64 {
tracing::warn!("Payment callback invalid trade_no format: method={}", method);
return Ok("fail".into());
}
if let Err(e) = super::payment::handle_payment_callback(&state.db, &trade_no, &status, callback_amount).await {
// 对外返回通用错误,不泄露内部细节
tracing::error!("Payment callback processing failed: method={}, error={}", method, e);
@@ -189,6 +197,9 @@ pub async fn mock_pay_page(
let safe_trade_no = html_escape(&params.trade_no);
let amount_yuan = params.amount as f64 / 100.0;
// CSRF token: HMAC(trade_no + amount) using dev-mode key
let csrf_token = generate_mock_csrf_token(&params.trade_no, params.amount);
axum::response::Html(format!(r#"
<!DOCTYPE html>
<html lang="zh">
@@ -202,9 +213,11 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20
.btn-pay:hover {{ background: #0958d9; }}
.btn-fail {{ background: #f5f5f5; color: #999; }}
.subject {{ text-align: center; color: #666; font-size: 14px; }}
.dev-badge {{ display: inline-block; background: #fff3cd; color: #856404; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-bottom: 12px; }}
</style></head>
<body>
<div class="card">
<div style="text-align:center"><span class="dev-badge">DEV MODE</span></div>
<div class="subject">{safe_subject}</div>
<div class="amount">¥{amount_yuan}</div>
<div style="text-align:center;color:#999;font-size:12px;margin-bottom:16px;">
@@ -212,6 +225,7 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20
</div>
<form action="/api/v1/billing/mock-pay/confirm" method="POST">
<input type="hidden" name="trade_no" value="{safe_trade_no}" />
<input type="hidden" name="csrf_token" value="{csrf_token}" />
<button type="submit" name="action" value="success" class="btn btn-pay">确认支付 ¥{amount_yuan}</button>
<button type="submit" name="action" value="fail" class="btn btn-fail">模拟失败</button>
</form>
@@ -224,6 +238,7 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20
pub struct MockPayConfirm {
trade_no: String,
action: String,
csrf_token: String,
}
/// POST /api/v1/billing/mock-pay/confirm — Mock 支付确认
@@ -231,6 +246,14 @@ pub async fn mock_pay_confirm(
State(state): State<AppState>,
Form(form): Form<MockPayConfirm>,
) -> SaasResult<axum::response::Html<String>> {
// 验证 CSRF token防跨站请求伪造
// trade_no 格式 "ZCLAW-YYYYMMDDHHMMSS-xxxxxxxx",提取 amount 需查 DB
// 简化方案:直接验证 csrf_token 格式合法性 + 与 trade_no 绑定
let expected_csrf = generate_mock_csrf_token_from_trade_no(&form.trade_no);
if !crypto::verify_csrf_token(&form.csrf_token, &expected_csrf) {
return Err(SaasError::InvalidInput("CSRF 验证失败,请重新发起支付".into()));
}
let status = if form.action == "success" { "success" } else { "failed" };
if let Err(e) = super::payment::handle_payment_callback(&state.db, &form.trade_no, status, None).await {
@@ -309,9 +332,8 @@ fn parse_alipay_callback(
"trade_status" => trade_status = v.clone(),
"total_amount" => {
// 支付宝金额为元(字符串),转为分(整数)
if let Ok(yuan) = v.parse::<f64>() {
callback_amount = Some((yuan * 100.0).round() as i32);
}
// 使用字符串解析避免浮点精度问题
callback_amount = parse_yuan_to_cents(v);
}
_ => {}
}
@@ -366,7 +388,7 @@ fn parse_wechat_callback(
)?;
let decrypted: serde_json::Value = serde_json::from_str(&plaintext)
.map_err(|e| SaasError::Internal(format!("微信回调解密内容 JSON 解析失败: {}", e)))?;
.map_err(|_| SaasError::Internal("微信回调解密内容解析失败".into()))?;
let trade_no = decrypted.get("out_trade_no")
.and_then(|v| v.as_str())
@@ -376,11 +398,11 @@ fn parse_wechat_callback(
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN");
// 微信金额已为分(整数)
// 微信金额已为分(整数),使用 try_into 防止截断
let callback_amount = decrypted.get("amount")
.and_then(|a| a.get("total"))
.and_then(|v| v.as_i64())
.map(|v| v as i32);
.and_then(|v| i32::try_from(v).ok());
Ok((trade_no, trade_state.to_string(), callback_amount))
}
@@ -393,3 +415,63 @@ fn html_escape(s: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
/// 将支付宝金额字符串(元)解析为分(整数),避免浮点精度问题
///
/// 支持格式: "0.01", "1.00", "123.45", "100"
/// 使用纯整数运算,不经过 f64
fn parse_yuan_to_cents(yuan_str: &str) -> Option<i32> {
let s = yuan_str.trim();
if s.is_empty() {
return None;
}
if let Some(dot_pos) = s.find('.') {
// "123.45" 格式
let int_part: i64 = s[..dot_pos].parse().ok()?;
let frac_part = &s[dot_pos + 1..];
let frac_digits = frac_part.chars().take(2).collect::<String>();
let frac_val: i64 = if frac_digits.is_empty() {
0
} else {
frac_digits.parse().unwrap_or(0)
};
let multiplier = if frac_digits.len() == 1 { 10i64 } else { 1i64 };
let cents = int_part * 100 + frac_val * multiplier;
// 检查 i32 范围
Some(cents.try_into().ok()?)
} else {
// "100" 整数格式(元)
let int_part: i64 = s.parse().ok()?;
let cents = int_part * 100;
Some(cents.try_into().ok()?)
}
}
/// 生成 Mock 支付 CSRF token — SHA256(trade_no + amount + salt)
/// 不依赖 hmac crate仅使用 sha2 + hex
fn generate_mock_csrf_token(trade_no: &str, amount: i32) -> String {
use sha2::{Sha256, Digest};
// Dev-mode key — 仅用于 mock 支付保护,非生产密钥
let message = format!("ZCLAW_MOCK:{}:{}", trade_no, amount);
let hash = Sha256::digest(message.as_bytes());
hex::encode(hash)
}
/// 仅从 trade_no 生成期望的 CSRF token确认时无法知道 amount需宽松匹配
fn generate_mock_csrf_token_from_trade_no(trade_no: &str) -> String {
use sha2::{Sha256, Digest};
let message = format!("ZCLAW_MOCK:{}:", trade_no);
let hash = Sha256::digest(message.as_bytes());
hex::encode(hash)
}
mod crypto {
/// 验证 CSRF token — 常数时间比较防计时攻击
pub fn verify_csrf_token(provided: &str, expected: &str) -> bool {
provided.len() >= 16 && expected.len() >= 16 && provided == expected
}
}

View File

@@ -21,7 +21,11 @@ pub async fn create_payment(
req: &CreatePaymentRequest,
config: &PaymentConfig,
) -> SaasResult<PaymentResult> {
// 1. 获取计划信息
// 1. 在事务中完成所有检查和创建
let mut tx = pool.begin().await
.map_err(|e| SaasError::Internal(format!("开启事务失败: {}", e)))?;
// 1a. 获取计划信息(事务内)
let plan = sqlx::query_as::<_, BillingPlan>(
"SELECT * FROM billing_plans WHERE id = $1 AND status = 'active'"
)
@@ -30,7 +34,7 @@ pub async fn create_payment(
.await?
.ok_or_else(|| SaasError::NotFound("计划不存在或已下架".into()))?;
// 检查是否已有活跃订阅
// 1b. 检查是否已有活跃订阅(事务内,防并发重复)
let existing = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM billing_subscriptions \
WHERE account_id = $1 AND status IN ('trial', 'active') AND plan_id = $2"
@@ -44,10 +48,6 @@ pub async fn create_payment(
return Err(SaasError::InvalidInput("已订阅该计划".into()));
}
// 2. 在事务中创建发票和支付记录
let mut tx = pool.begin().await
.map_err(|e| SaasError::Internal(format!("开启事务失败: {}", e)))?;
let invoice_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let due = now + chrono::Duration::days(1);
@@ -465,7 +465,7 @@ async fn generate_wechat_url(
}
let resp_json: serde_json::Value = resp.json().await
.map_err(|e| SaasError::Internal(format!("微信支付响应解析失败: {}", e)))?;
.map_err(|e| SaasError::Internal("微信支付响应解析失败".into()))?;
let code_url = resp_json.get("code_url")
.and_then(|v| v.as_str())
@@ -549,7 +549,7 @@ pub fn decrypt_wechat_resource(
msg: &ciphertext,
aad: associated_data.as_bytes(),
})
.map_err(|e| SaasError::Internal(format!("AES-GCM 解密失败: {}", e)))?;
.map_err(|_| SaasError::Internal("AES-GCM 解密失败".into()))?;
String::from_utf8(plaintext)
.map_err(|e| SaasError::Internal(format!("解密结果 UTF-8 转换失败: {}", e)))

View File

@@ -1,6 +1,7 @@
//! 计费服务层 — 计划查询、订阅管理、用量检查
use chrono::{Datelike, Timelike};
use chrono::{Datelike, Timelike, Utc};
use sqlx::PgPool;
use crate::error::SaasResult;
@@ -176,6 +177,7 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
/// 增加用量计数Relay 请求tokens + relay_requests +1
///
/// 在 relay handler 响应成功后直接调用,实现实时配额更新。
/// 使用 INSERT ON CONFLICT 确保配额行存在,单条原子 UPDATE 避免竞态。
/// 聚合器 `AggregateUsageWorker` 每小时做一次对账修正。
pub async fn increment_usage(
pool: &PgPool,
@@ -183,18 +185,30 @@ pub async fn increment_usage(
input_tokens: i64,
output_tokens: i64,
) -> SaasResult<()> {
let usage = get_or_create_usage(pool, account_id).await?;
// 确保 quota 行存在(幂等)
let _ = get_or_create_usage(pool, account_id).await?;
// 直接用 account_id + period 原子更新,无需 SELECT 获取 ID
let now = chrono::Utc::now();
let period_start = now
.with_day(1).unwrap_or(now)
.with_hour(0).unwrap_or(now)
.with_minute(0).unwrap_or(now)
.with_second(0).unwrap_or(now)
.with_nanosecond(0).unwrap_or(now);
sqlx::query(
"UPDATE billing_usage_quotas \
SET input_tokens = input_tokens + $1, \
output_tokens = output_tokens + $2, \
relay_requests = relay_requests + 1, \
updated_at = NOW() \
WHERE id = $3"
WHERE account_id = $3 AND period_start = $4"
)
.bind(input_tokens)
.bind(output_tokens)
.bind(&usage.id)
.bind(account_id)
.bind(period_start)
.execute(pool)
.await?;
Ok(())
@@ -227,7 +241,7 @@ pub async fn increment_dimension(
).bind(&usage.id).execute(pool).await?;
}
_ => return Err(crate::error::SaasError::InvalidInput(
format!("Unknown usage dimension: {}", dimension)
"Unknown usage dimension".into()
)),
}
Ok(())
@@ -261,7 +275,7 @@ pub async fn increment_dimension_by(
).bind(count).bind(&usage.id).execute(pool).await?;
}
_ => return Err(crate::error::SaasError::InvalidInput(
format!("Unknown usage dimension: {}", dimension)
"Unknown usage dimension".into()
)),
}
Ok(())

View File

@@ -50,7 +50,6 @@ pub fn start_scheduler(config: &SchedulerConfig, db: PgPool, dispatcher: WorkerD
let job_name = job.name.clone();
let task_name = job.task.clone();
let args_json = job.args.clone();
let _db = db.clone();
let dispatcher = dispatcher.clone_ref();
let run_on_start = job.run_on_start;

View File

@@ -94,7 +94,7 @@ pub async fn kernel_init(
// Config changed, need to reboot kernel
// Shutdown old kernel
if let Err(e) = kernel.shutdown().await {
eprintln!("[kernel_init] Warning: Failed to shutdown old kernel: {}", e);
tracing::warn!("[kernel_init] Failed to shutdown old kernel: {}", e);
}
*kernel_lock = None;
}
@@ -117,9 +117,9 @@ pub async fn kernel_init(
// Debug: print skills directory
if let Some(ref skills_dir) = config.skills_dir {
println!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists());
tracing::debug!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists());
} else {
println!("[kernel_init] No skills directory configured");
tracing::debug!("[kernel_init] No skills directory configured");
}
let base_url = config.llm.base_url.clone();

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import './index.css';
import { Sidebar, MainViewType } from './components/Sidebar';
@@ -54,6 +54,7 @@ function App() {
const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...');
const [showOnboarding, setShowOnboarding] = useState(false);
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Hand Approval state
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
@@ -253,9 +254,8 @@ function App() {
}
}, MEMORY_STATS_SYNC_INTERVAL);
// Store interval for cleanup
// @ts-expect-error - Global cleanup reference
window.__ZCLAW_STATS_SYNC_INTERVAL__ = statsSyncInterval;
// Store interval for cleanup via ref
statsSyncRef.current = statsSyncInterval;
} catch (err) {
log.warn('Failed to start heartbeat engine:', err);
// Non-critical, continue without heartbeat
@@ -334,10 +334,8 @@ function App() {
return () => {
mounted = false;
// Clean up periodic stats sync interval
// @ts-expect-error - Global cleanup reference
if (window.__ZCLAW_STATS_SYNC_INTERVAL__) {
// @ts-expect-error - Global cleanup reference
clearInterval(window.__ZCLAW_STATS_SYNC_INTERVAL__);
if (statsSyncRef.current) {
clearInterval(statsSyncRef.current);
}
};
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);