fix(security): 安全加固 — analytics 权限校验 + HSTS/CSP 安全头 + SSE no-cache + SQL 参数化

- analytics batch() 添加 require_permission + 事件数上限 100
- main.rs 添加 HSTS/Content-Security-Policy/Permissions-Policy 安全头
- sse_handler SSE 响应添加 Cache-Control: no-store 防 token 泄漏
- action_inbox_service SQL 查询改为参数化,防注入
- wechat_handler 日志脱敏,不打印 appid/secret 长度
- dynamic_table sanitize_identifier 添加 63 字节限制
This commit is contained in:
iven
2026-05-20 17:52:28 +08:00
parent fa1dc764a3
commit 65cf96f119
6 changed files with 130 additions and 28 deletions

View File

@@ -1,8 +1,13 @@
use axum::Json;
use axum::extract::Extension;
use serde::Deserialize;
use tracing;
use erp_core::types::ApiResponse;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
const MAX_EVENTS_PER_BATCH: usize = 100;
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // 客户端上报结构体,字段后续接入分析表时使用
@@ -37,7 +42,17 @@ pub struct BatchRequest {
/// 接收小程序批量埋点事件。
/// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。
pub async fn batch(Json(req): Json<BatchRequest>) -> Json<ApiResponse<()>> {
pub async fn batch(
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BatchRequest>,
) -> Result<Json<ApiResponse<()>>, AppError> {
require_permission(&ctx, "system.analytics.submit")?;
if req.events.len() > MAX_EVENTS_PER_BATCH {
return Err(AppError::Validation(format!(
"批量埋点事件数不能超过 {}",
MAX_EVENTS_PER_BATCH
)));
}
for evt in &req.events {
tracing::info!(
event = %evt.event,
@@ -46,5 +61,5 @@ pub async fn batch(Json(req): Json<BatchRequest>) -> Json<ApiResponse<()>> {
"Analytics event received"
);
}
Json(ApiResponse::ok(()))
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -921,6 +921,22 @@ async fn security_headers_middleware(
header::HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
headers.insert(
header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"),
);
headers.insert(
header::HeaderName::from_static("content-security-policy"),
HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob: https:; connect-src 'self' wss:; \
frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
),
);
headers.insert(
header::HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
);
response
}