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:
@@ -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(),
|
||||
"微信登录请求"
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user