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
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:
@@ -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 }
|
||||
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 将支付宝金额字符串(元)解析为分(整数),避免浮点精度问题
|
||||
///
|
||||
/// 支持格式: "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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user