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

@@ -37,8 +37,8 @@ where
tracing::info!(
code = %req.code,
tenant_id = %state.default_tenant_id,
appid_len = state.wechat_appid.len(),
secret_len = state.wechat_secret.len(),
has_appid = !state.wechat_appid.is_empty(),
has_secret = !state.wechat_secret.is_empty(),
"微信登录请求"
);

View File

@@ -237,31 +237,72 @@ pub async fn list_action_items(
let filter_by_user = query.assigned_to_me.unwrap_or(false) && user_id.is_some();
// 各段的 status 过滤条件
// 各段的 status 过滤条件(参数化)
let (sug_status, alert_status, fu_status) = match query.status.as_deref() {
Some("pending") => (
"AND s.status = 'pending'".into(),
"AND al.status = 'active'".into(),
"AND f.status = 'pending'".into(),
"AND s.status = $6".to_string(),
"AND al.status = $7".to_string(),
"AND f.status = $8".to_string(),
),
Some("in_progress") => (
"AND s.status = 'approved'".into(),
"AND al.status = 'acknowledged'".into(),
"AND f.status = 'in_progress'".into(),
"AND s.status = $6".to_string(),
"AND al.status = $7".to_string(),
"AND f.status = $8".to_string(),
),
Some("completed") => (
"AND s.status = 'executed'".into(),
"AND al.status = 'resolved'".into(),
"AND f.status = 'completed'".into(),
"AND s.status = $6".to_string(),
"AND al.status = $7".to_string(),
"AND f.status = $8".to_string(),
),
Some("dismissed") => (
"AND s.status IN ('rejected', 'expired', 'parse_failed')".into(),
"AND al.status IN ('dismissed', 'expired')".into(),
"AND f.status IN ('cancelled', 'skipped')".into(),
"AND s.status = ANY($6)".to_string(),
"AND al.status = ANY($7)".to_string(),
"AND f.status = ANY($8)".to_string(),
),
_ => (String::new(), String::new(), String::new()),
};
// status 绑定值
let (sug_val, alert_val, fu_val): (sea_orm::Value, sea_orm::Value, sea_orm::Value) =
match query.status.as_deref() {
Some("pending") => ("pending".into(), "active".into(), "pending".into()),
Some("in_progress") => (
"approved".into(),
"acknowledged".into(),
"in_progress".into(),
),
Some("completed") => ("executed".into(), "resolved".into(), "completed".into()),
Some("dismissed") => (
sea_orm::Value::Array(
sea_orm::sea_query::ArrayType::String,
Some(Box::new(vec![
sea_orm::Value::String(Some(Box::new("rejected".to_string()))),
sea_orm::Value::String(Some(Box::new("expired".to_string()))),
sea_orm::Value::String(Some(Box::new("parse_failed".to_string()))),
])),
),
sea_orm::Value::Array(
sea_orm::sea_query::ArrayType::String,
Some(Box::new(vec![
sea_orm::Value::String(Some(Box::new("dismissed".to_string()))),
sea_orm::Value::String(Some(Box::new("expired".to_string()))),
])),
),
sea_orm::Value::Array(
sea_orm::sea_query::ArrayType::String,
Some(Box::new(vec![
sea_orm::Value::String(Some(Box::new("cancelled".to_string()))),
sea_orm::Value::String(Some(Box::new("skipped".to_string()))),
])),
),
),
_ => (
sea_orm::Value::String(None),
sea_orm::Value::String(None),
sea_orm::Value::String(None),
),
};
// 按类型过滤
let type_filter = query.action_type.as_deref();
let include_sug = type_filter.is_none_or(|t| t == "ai_suggestion");
@@ -335,7 +376,7 @@ pub async fn list_action_items(
let union_sql = segments.join("\n UNION ALL\n");
// $1=tenant_id, $2=patient_id, $3=assigned_to (union 内部)
// $1=tenant_id, $2=patient_id, $3=assigned_to, $6/$7/$8=status (union 内部)
// $4=LIMIT, $5=OFFSET (外层分页)
let patient_val: sea_orm::Value = query
.patient_id
@@ -362,6 +403,9 @@ pub async fn list_action_items(
assigned_val.clone(),
(page_size as i64).into(),
(offset as i64).into(),
sug_val.clone(),
alert_val.clone(),
fu_val.clone(),
],
))
.all(db)
@@ -375,7 +419,14 @@ pub async fn list_action_items(
FromQueryResult::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
count_sql,
[tenant_id.into(), patient_val, assigned_val],
[
tenant_id.into(),
patient_val,
assigned_val,
sug_val,
alert_val,
fu_val,
],
))
.one(db)
.await

View File

@@ -1,9 +1,9 @@
use std::cell::Cell;
use std::collections::HashSet;
use std::convert::Infallible;
use axum::extract::{Extension, Query};
use axum::http::HeaderMap;
use axum::http::{HeaderMap, HeaderValue, header};
use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream;
use sea_orm::ConnectionTrait;
@@ -13,6 +13,23 @@ use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::TenantContext;
/// 包装 SSE 响应,添加 Cache-Control: no-store 头
pub struct NoCacheSse<S>(Sse<S>);
impl<S> IntoResponse for NoCacheSse<S>
where
S: Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static,
{
fn into_response(self) -> axum::response::Response {
let mut response = self.0.into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, no-cache, must-revalidate"),
);
response
}
}
use crate::message_state::MessageState;
/// SSE 查询参数
@@ -38,7 +55,7 @@ pub async fn message_stream(
Extension(ctx): Extension<TenantContext>,
headers: HeaderMap,
Query(query): Query<SseQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
) -> Result<NoCacheSse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
let user_id = ctx.user_id;
let tenant_id = ctx.tenant_id;
@@ -165,10 +182,12 @@ pub async fn message_stream(
}
};
Ok(Sse::new(sse_stream).keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(30))
.text("ping"),
Ok(NoCacheSse(
Sse::new(sse_stream).keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(30))
.text("ping"),
),
))
}

View File

@@ -6,7 +6,7 @@ use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use crate::error::{PluginError, PluginResult};
use crate::manifest::{PluginEntity, PluginField, PluginFieldType};
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,防止 SQL 注入
/// 消毒标识符:只保留 ASCII 字母、数字、下划线,限制 63 字节PostgreSQL NAMEDATALEN-1
pub(crate) fn sanitize_identifier(input: &str) -> String {
input
.chars()
@@ -17,6 +17,7 @@ pub(crate) fn sanitize_identifier(input: &str) -> String {
'_'
}
})
.take(63)
.collect()
}

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
}