fix(security): 补全 XSS sanitize + 修复 sender_id 身份伪造

安全审计修复:
- 补全 6 个 DTO 的 sanitize 方法(diagnosis/consent/alert/medication_record/medication_reminder/follow_up_template)
- 4 个 handler 添加 .sanitize() 调用(diagnosis/consent/alert_rule/medication_record)
- 修复咨询消息 sender_id/sender_role 从客户端提交改为服务端从 JWT 提取
- 修复小程序 AI 报告 markdownToHtml XSS(添加 sanitizeHtml 过滤)
This commit is contained in:
iven
2026-04-30 10:21:52 +08:00
parent d8735eb45c
commit 931edc3025
15 changed files with 154 additions and 83 deletions

View File

@@ -12,8 +12,26 @@ const TYPE_LABELS: Record<string, string> = {
report_summary_generation: '报告摘要',
};
/** 移除危险的 HTML 标签和事件属性,防止 XSS */
function sanitizeHtml(html: string): string {
return html
// 移除 <script> 标签及其内容
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// 移除 <iframe>, <object>, <embed>, <form>, <input>, <textarea>, <style> 标签
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
// 移除 <link> 和 <meta> 标签
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
// 移除所有 on* 事件属性 (onclick, onerror, onload 等)
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
// 移除 javascript: 和 data: 协议的 href/src 属性
.replace(/(href|src)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '')
.replace(/(href|src)\s*=\s*(?:"data:[^"]*"|'data:[^']*')/gi, '');
}
function markdownToHtml(md: string): string {
return md
// 先转义 markdown 中可能存在的原始 HTML 标签
const escaped = sanitizeHtml(md);
return escaped
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')

View File

@@ -37,8 +37,6 @@ export interface Message {
export interface CreateMessageReq {
session_id: string;
sender_id: string;
sender_role: string;
content_type?: string;
content: string;
}

View File

@@ -1,4 +1,5 @@
use chrono::{DateTime, Utc};
use erp_core::sanitize::{sanitize_option, sanitize_string};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
@@ -18,6 +19,13 @@ pub struct CreateAlertRuleRequest {
pub cooldown_minutes: Option<i32>,
}
impl CreateAlertRuleRequest {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateAlertRuleRequest {
pub name: Option<String>,
@@ -30,6 +38,13 @@ pub struct UpdateAlertRuleRequest {
pub version: i32,
}
impl UpdateAlertRuleRequest {
pub fn sanitize(&mut self) {
self.name = sanitize_option(self.name.take());
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Serialize, ToSchema)]
pub struct AlertRuleResponse {
pub id: Uuid,

View File

@@ -1,4 +1,5 @@
use chrono::{NaiveDate, Utc};
use erp_core::sanitize::{sanitize_option, sanitize_string};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
@@ -32,8 +33,24 @@ pub struct CreateConsentReq {
pub notes: Option<String>,
}
impl CreateConsentReq {
pub fn sanitize(&mut self) {
self.consent_type = sanitize_string(&self.consent_type);
self.consent_scope = sanitize_string(&self.consent_scope);
self.consent_method = sanitize_option(self.consent_method.take());
self.witness_name = sanitize_option(self.witness_name.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RevokeConsentReq {
pub notes: Option<String>,
pub version: i32,
}
impl RevokeConsentReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
}
}

View File

@@ -32,11 +32,10 @@ pub struct MessageResp {
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// 发送消息请求体 — 不含 sender_id/sender_role由服务端从 JWT 注入。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateMessageReq {
pub session_id: Uuid,
pub sender_id: Uuid,
pub sender_role: String,
pub content_type: Option<String>,
pub content: String,
}

View File

@@ -1,3 +1,4 @@
use erp_core::sanitize::{sanitize_option, sanitize_string};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -15,6 +16,16 @@ pub struct CreateDiagnosisReq {
pub notes: Option<String>,
}
impl CreateDiagnosisReq {
pub fn sanitize(&mut self) {
self.icd_code = sanitize_string(&self.icd_code);
self.diagnosis_name = sanitize_string(&self.diagnosis_name);
self.diagnosis_type = sanitize_string(&self.diagnosis_type);
self.status = sanitize_string(&self.status);
self.notes = sanitize_option(self.notes.take());
}
}
fn default_diagnosis_type() -> String { "primary".to_string() }
fn default_status() -> String { "active".to_string() }
@@ -30,6 +41,16 @@ pub struct UpdateDiagnosisReq {
pub notes: Option<String>,
}
impl UpdateDiagnosisReq {
pub fn sanitize(&mut self) {
self.icd_code = sanitize_option(self.icd_code.take());
self.diagnosis_name = sanitize_option(self.diagnosis_name.take());
self.diagnosis_type = sanitize_option(self.diagnosis_type.take());
self.status = sanitize_option(self.status.take());
self.notes = sanitize_option(self.notes.take());
}
}
#[derive(Debug, Serialize, ToSchema)]
pub struct DiagnosisResp {
pub id: uuid::Uuid,

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::sanitize::sanitize_option;
use erp_core::sanitize::{sanitize_option, sanitize_string};
// ── 模板字段 DTO ──
@@ -22,6 +22,16 @@ pub struct TemplateFieldReq {
pub sort_order: i32,
}
impl TemplateFieldReq {
pub fn sanitize(&mut self) {
self.label = sanitize_string(&self.label);
self.field_key = sanitize_string(&self.field_key);
self.field_type = sanitize_string(&self.field_type);
self.options = sanitize_option(self.options.take());
self.placeholder = sanitize_option(self.placeholder.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TemplateFieldResp {
pub id: Uuid,
@@ -62,9 +72,12 @@ pub struct CreateFollowUpTemplateReq {
impl CreateFollowUpTemplateReq {
pub fn sanitize(&mut self) {
self.name = self.name.trim().to_string();
self.name = sanitize_string(&self.name);
self.description = sanitize_option(self.description.take());
self.applicable_scope = sanitize_option(self.applicable_scope.take());
for field in &mut self.fields {
field.sanitize();
}
}
}
@@ -81,11 +94,14 @@ pub struct UpdateFollowUpTemplateReq {
impl UpdateFollowUpTemplateReq {
pub fn sanitize(&mut self) {
if let Some(ref mut n) = self.name {
*n = n.trim().to_string();
}
self.name = sanitize_option(self.name.take());
self.description = sanitize_option(self.description.take());
self.applicable_scope = sanitize_option(self.applicable_scope.take());
if let Some(ref mut fields) = self.fields {
for field in fields.iter_mut() {
field.sanitize();
}
}
}
}

View File

@@ -1,3 +1,4 @@
use erp_core::sanitize::{sanitize_option, sanitize_string};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -71,55 +72,29 @@ pub struct MedicationRecordResp {
/// 创建请求的输入清洗
impl CreateMedicationRecordReq {
/// 清洗用户输入:去除首尾空白,截断超长字段
/// 清洗用户输入:去除 HTML 标签和首尾空白
pub fn sanitize(&mut self) {
self.medication_name = self.medication_name.trim().to_string();
if let Some(ref mut v) = self.generic_name {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.dosage {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.unit {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.frequency {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.route {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.notes {
*v = v.trim().to_string();
}
self.medication_name = sanitize_string(&self.medication_name);
self.generic_name = sanitize_option(self.generic_name.take());
self.dosage = sanitize_option(self.dosage.take());
self.unit = sanitize_option(self.unit.take());
self.frequency = sanitize_option(self.frequency.take());
self.route = sanitize_option(self.route.take());
self.notes = sanitize_option(self.notes.take());
}
}
/// 更新请求的输入清洗
impl UpdateMedicationRecordReq {
/// 清洗用户输入:去除首尾空白
/// 清洗用户输入:去除 HTML 标签和首尾空白
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.medication_name {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.generic_name {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.dosage {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.unit {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.frequency {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.route {
*v = v.trim().to_string();
}
if let Some(ref mut v) = self.notes {
*v = v.trim().to_string();
}
self.medication_name = sanitize_option(self.medication_name.take());
self.generic_name = sanitize_option(self.generic_name.take());
self.dosage = sanitize_option(self.dosage.take());
self.unit = sanitize_option(self.unit.take());
self.frequency = sanitize_option(self.frequency.take());
self.route = sanitize_option(self.route.take());
self.notes = sanitize_option(self.notes.take());
}
}

View File

@@ -1,3 +1,4 @@
use erp_core::sanitize::{sanitize_option, sanitize_string};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -47,19 +48,19 @@ pub struct MedicationReminderResp {
impl CreateMedicationReminderReq {
pub fn sanitize(&mut self) {
self.medication_name = self.medication_name.trim().to_string();
if let Some(ref mut d) = self.dosage { *d = d.trim().to_string(); }
if let Some(ref mut f) = self.frequency { *f = f.trim().to_string(); }
if let Some(ref mut n) = self.notes { *n = n.trim().to_string(); }
self.medication_name = sanitize_string(&self.medication_name);
self.dosage = sanitize_option(self.dosage.take());
self.frequency = sanitize_option(self.frequency.take());
self.notes = sanitize_option(self.notes.take());
}
}
impl UpdateMedicationReminderReq {
pub fn sanitize(&mut self) {
if let Some(ref mut n) = self.medication_name { *n = n.trim().to_string(); }
if let Some(ref mut d) = self.dosage { *d = d.trim().to_string(); }
if let Some(ref mut f) = self.frequency { *f = f.trim().to_string(); }
if let Some(ref mut n) = self.notes { *n = n.trim().to_string(); }
self.medication_name = sanitize_option(self.medication_name.take());
self.dosage = sanitize_option(self.dosage.take());
self.frequency = sanitize_option(self.frequency.take());
self.notes = sanitize_option(self.notes.take());
}
}

View File

@@ -54,13 +54,14 @@ where
pub async fn create<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
axum::Json(body): axum::Json<CreateAlertRuleRequest>,
axum::Json(mut body): axum::Json<CreateAlertRuleRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alert-rules.manage")?;
body.sanitize();
let rule = alert_rule_service::create_rule(
&state, ctx.tenant_id, ctx.user_id, body,
).await?;
@@ -71,13 +72,14 @@ pub async fn update<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<UpdateAlertRuleRequest>,
axum::Json(mut body): axum::Json<UpdateAlertRuleRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alert-rules.manage")?;
body.sanitize();
let rule = alert_rule_service::update_rule(
&state, ctx.tenant_id, id, ctx.user_id, body,
).await?;

View File

@@ -25,7 +25,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.list")?;
require_permission(&ctx, "health.consent.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = consent_service::list_consents(
@@ -44,7 +44,9 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
require_permission(&ctx, "health.consent.manage")?;
let mut req = req;
req.sanitize();
let result = consent_service::grant_consent(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -62,7 +64,9 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
require_permission(&ctx, "health.consent.manage")?;
let mut req = req;
req.sanitize();
let result = consent_service::revoke_consent(
&state, ctx.tenant_id, consent_id, Some(ctx.user_id), req,
)

View File

@@ -149,6 +149,7 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
// 从 JWT 身份推导 sender_role不信任客户端输入
let is_doctor = crate::entity::doctor_profile::Entity::find()
.filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id))
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
@@ -157,16 +158,15 @@ where
.await
.map_err(|e| AppError::Internal(e.to_string()))?
.is_some();
let sender_role = if is_doctor { "doctor" } else { "patient" }.to_string();
let mut msg_req = CreateMessageReq {
session_id: req.session_id,
sender_id: ctx.user_id,
sender_role: if is_doctor { "doctor" } else { "patient" }.to_string(),
content_type: req.content_type,
content: req.content,
};
msg_req.sanitize();
let result = consultation_service::create_message(
&state, ctx.tenant_id, Some(ctx.user_id), msg_req,
&state, ctx.tenant_id, Some(ctx.user_id), ctx.user_id, sender_role, msg_req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -47,6 +47,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let mut req = req;
req.sanitize();
let result = diagnosis_service::create_diagnosis(
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
)
@@ -65,8 +67,10 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let mut data = req.data;
data.sanitize();
let result = diagnosis_service::update_diagnosis(
&state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), req.data, req.version,
&state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))

View File

@@ -27,7 +27,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
require_permission(&ctx, "health.medication-records.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = medication_record_service::list_medications(
@@ -47,7 +47,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
require_permission(&ctx, "health.medication-records.list")?;
let result =
medication_record_service::get_medication(&state, ctx.tenant_id, record_id).await?;
Ok(Json(ApiResponse::ok(result)))
@@ -63,7 +63,9 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
require_permission(&ctx, "health.medication-records.manage")?;
let mut req = req;
req.sanitize();
let result = medication_record_service::create_medication(
&state,
ctx.tenant_id,
@@ -85,13 +87,15 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
require_permission(&ctx, "health.medication-records.manage")?;
let mut data = req.data;
data.sanitize();
let result = medication_record_service::update_medication(
&state,
ctx.tenant_id,
record_id,
Some(ctx.user_id),
req.data,
data,
req.version,
)
.await?;
@@ -109,7 +113,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
require_permission(&ctx, "health.medication-records.manage")?;
medication_record_service::delete_medication(
&state,
ctx.tenant_id,

View File

@@ -318,6 +318,8 @@ pub async fn create_message(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
sender_id: Uuid,
sender_role: String,
req: CreateMessageReq,
) -> HealthResult<MessageResp> {
// 校验会话存在且状态为 active 或 waiting
@@ -335,17 +337,12 @@ pub async fn create_message(
.ok_or(HealthError::ConsultationNotFound)?;
let now = Utc::now();
validate_sender_role(&req.sender_role)?;
validate_sender_role(&sender_role)?;
let content_type = req.content_type.unwrap_or_else(|| "text".to_string());
validate_content_type(&content_type)?;
let is_patient = req.sender_role == "patient";
let is_patient = sender_role == "patient";
let should_activate = session.status == "waiting";
// 强制 sender_id 为认证用户,防止冒充
let sender_id = operator_id.ok_or_else(|| {
HealthError::Validation("sender_id 必须与认证用户匹配".into())
})?;
// 事务包裹:消息 INSERT + 会话 CAS 更新,保证原子性
let txn = state.db.begin().await?;
@@ -355,7 +352,7 @@ pub async fn create_message(
tenant_id: Set(tenant_id),
session_id: Set(req.session_id),
sender_id: Set(sender_id),
sender_role: Set(req.sender_role),
sender_role: Set(sender_role),
content_type: Set(content_type),
content: Set(pii::encrypt(state.crypto.kek(), &req.content)?),
is_read: Set(false),