Compare commits

...

7 Commits

Author SHA1 Message Date
iven
f7bf5a86ea fix(server): CORS 生产环境拒绝通配符
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-06 10:21:50 +08:00
iven
d9818c263e fix(ai): AI 提示词模板添加安全检查 2026-05-06 10:21:35 +08:00
iven
c452ae81d1 fix(health): OAuth JWT 配置缺失返回错误而非 panic 2026-05-06 10:21:25 +08:00
iven
a1cbb9fb1d fix(server): readiness_check 隐藏内部错误详情 2026-05-06 10:21:13 +08:00
iven
a78ee2f154 fix(auth): Token 验证和撤销添加租户隔离 2026-05-06 10:21:07 +08:00
iven
51c41acfa7 fix(health): 审计日志加密字段替换为 REDACTED 2026-05-06 10:21:02 +08:00
iven
f668e64266 fix(health): FHIR converter 身份证号脱敏处理 2026-05-06 10:20:50 +08:00
8 changed files with 138 additions and 32 deletions

View File

@@ -426,6 +426,8 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
validate_prompt_safety(&body.system_prompt)?;
validate_prompt_safety(&body.user_prompt_template)?;
let prompt = state
.prompt
.create_prompt(
@@ -683,3 +685,24 @@ fn build_sse_stream(
yield Ok(Event::default().event("done").data(data));
}
}
/// 检查提示词内容是否包含可疑注入模式
fn validate_prompt_safety(content: &str) -> Result<(), erp_core::error::AppError> {
let suspicious = [
"ignore previous",
"ignore all previous",
"ignore above",
"disregard previous",
"you are now",
"new instructions:",
];
let lower = content.to_lowercase();
for pattern in &suspicious {
if lower.contains(pattern) {
return Err(erp_core::error::AppError::Validation(
format!("提示词内容包含不安全模式: {}", pattern),
));
}
}
Ok(())
}

View File

@@ -195,7 +195,7 @@ impl AuthService {
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
// Revoke the old token (rotation)
TokenService::revoke_token(old_token_id, db).await?;
TokenService::revoke_token(old_token_id, claims.sub, db).await?;
// Fetch fresh roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;

View File

@@ -131,6 +131,7 @@ impl TokenService {
let hash = sha256_hex(token);
let token_row = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(hash))
.filter(user_token::Column::TenantId.eq(claims.tid))
.filter(user_token::Column::RevokedAt.is_null())
.one(db)
.await
@@ -151,8 +152,10 @@ impl TokenService {
}
/// Revoke a specific refresh token by database ID.
pub async fn revoke_token(token_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
/// Verifies that the token belongs to the specified user for security.
pub async fn revoke_token(token_id: Uuid, user_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
let token_row = user_token::Entity::find_by_id(token_id)
.filter(user_token::Column::UserId.eq(user_id))
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?

View File

@@ -32,9 +32,15 @@ pub fn patient_to_fhir(p: &patient::Model) -> serde_json::Value {
}
if let Some(ref id_number) = p.id_number {
// 加密密文v1| 前缀)不输出,明文做脱敏处理
let display_value = if id_number.starts_with("v1|") {
"[REDACTED]".to_string()
} else {
mask_sensitive(id_number)
};
result["identifier"] = serde_json::json!([{
"system": "urn:oid:2.16.156.10011.1.3",
"value": id_number,
"value": display_value,
}]);
}
@@ -332,6 +338,15 @@ pub fn follow_up_to_fhir(t: &follow_up_task::Model) -> serde_json::Value {
})
}
/// 对敏感字符串做脱敏:保留前 1 位和后 4 位,中间用 * 替代
fn mask_sensitive(s: &str) -> String {
if s.len() <= 5 {
"*".repeat(s.len())
} else {
format!("{}{}{}", &s[..1], "*".repeat(s.len() - 5), &s[s.len() - 4..])
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -370,7 +385,47 @@ mod tests {
assert_eq!(fhir["id"], "00000000-0000-0000-0000-000000000001");
assert_eq!(fhir["gender"], "male");
assert_eq!(fhir["birthDate"], "1968-05-15");
assert_eq!(fhir["identifier"][0]["value"], "110101196805150001");
assert_eq!(fhir["identifier"][0]["value"], "1*************0001");
}
#[test]
fn test_patient_to_fhir_encrypted_id_number_redacted() {
let p = patient::Model {
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
tenant_id: uuid::Uuid::now_v7(),
user_id: None,
name: "测试".into(),
gender: None,
birth_date: None,
blood_type: None,
id_number: Some("v1|encrypted_payload_here".into()),
id_number_hash: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
emergency_contact_phone_hash: None,
key_version: None,
status: "active".into(),
verification_status: "pending".into(),
source: None,
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
created_by: None,
updated_by: None,
deleted_at: None,
version: 1,
};
let fhir = patient_to_fhir(&p);
assert_eq!(fhir["identifier"][0]["value"], "[REDACTED]");
}
#[test]
fn test_mask_sensitive() {
assert_eq!(mask_sensitive("110101196805150001"), "1*************0001");
assert_eq!(mask_sensitive("12345"), "*****");
assert_eq!(mask_sensitive("123456"), "1*3456");
}
#[test]

View File

@@ -18,8 +18,16 @@ pub async fn token(
State(state): State<HealthState>,
Json(req): Json<TokenRequest>,
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET")
.expect("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法签发 OAuth token");
let jwt_secret = match std::env::var("ERP__AUTH__JWT_SECRET") {
Ok(s) => s,
Err(_) => {
tracing::error!("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法签发 OAuth token");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(TokenErrorResponse::invalid_client("服务配置错误")),
));
}
};
match OAuthService::token(&state.db, &req, &jwt_secret).await {
Ok(resp) => Ok((StatusCode::OK, Json(resp))),

View File

@@ -283,10 +283,10 @@ pub async fn update_patient(
])?;
}
// 记录变更前的关键临床值(过敏史、病史、身份证号
// 记录变更前的关键临床值(加密字段用 REDACTED 替代
let old_snapshot = serde_json::json!({
"allergy_history": model.allergy_history,
"medical_history_summary": model.medical_history_summary,
"allergy_history": model.allergy_history.as_ref().map(|_| "[REDACTED]"),
"medical_history_summary": model.medical_history_summary.as_ref().map(|_| "[REDACTED]"),
"status": model.status,
"verification_status": model.verification_status,
});
@@ -329,10 +329,10 @@ pub async fn update_patient(
let updated = active.update(&state.db).await?;
// 变更后快照
// 变更后快照(加密字段用 REDACTED 替代)
let new_snapshot = serde_json::json!({
"allergy_history": updated.allergy_history,
"medical_history_summary": updated.medical_history_summary,
"allergy_history": updated.allergy_history.as_ref().map(|_| "[REDACTED]"),
"medical_history_summary": updated.medical_history_summary.as_ref().map(|_| "[REDACTED]"),
"status": updated.status,
"verification_status": updated.verification_status,
});

View File

@@ -91,11 +91,14 @@ async fn check_database(db: &sea_orm::DatabaseConnection) -> ComponentStatus {
latency_ms: Some(start.elapsed().as_millis() as u64),
error: None,
},
Err(e) => ComponentStatus {
status: "error".to_string(),
latency_ms: Some(start.elapsed().as_millis() as u64),
error: Some(e.to_string()),
},
Err(e) => {
tracing::error!(error = %e, "Database health check failed");
ComponentStatus {
status: "error".to_string(),
latency_ms: Some(start.elapsed().as_millis() as u64),
error: Some("connection failed".to_string()),
}
}
}
}
@@ -112,18 +115,24 @@ async fn check_redis(client: &redis::Client) -> ComponentStatus {
latency_ms: Some(start.elapsed().as_millis() as u64),
error: None,
},
Err(e) => ComponentStatus {
status: "error".to_string(),
latency_ms: Some(start.elapsed().as_millis() as u64),
error: Some(e.to_string()),
},
Err(e) => {
tracing::error!(error = %e, "Redis PING failed");
ComponentStatus {
status: "error".to_string(),
latency_ms: Some(start.elapsed().as_millis() as u64),
error: Some("connection failed".to_string()),
}
}
}
}
Err(e) => {
tracing::error!(error = %e, "Redis connection failed");
ComponentStatus {
status: "error".to_string(),
latency_ms: Some(start.elapsed().as_millis() as u64),
error: Some("connection failed".to_string()),
}
}
Err(e) => ComponentStatus {
status: "error".to_string(),
latency_ms: Some(start.elapsed().as_millis() as u64),
error: Some(e.to_string()),
},
}
}

View File

@@ -808,11 +808,19 @@ fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer {
.collect::<Vec<_>>();
if origins.len() == 1 && origins[0] == "*" {
tracing::warn!(
"⚠️ CORS 允许所有来源 — 仅限开发环境使用!\
生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名"
);
return tower_http::cors::CorsLayer::permissive();
#[cfg(not(debug_assertions))]
{
tracing::error!("CORS wildcard '*' is not allowed in production builds");
panic!("Refusing to start with CORS wildcard in release mode. Set ERP__CORS__ALLOWED_ORIGINS to specific domains.");
}
#[cfg(debug_assertions)]
{
tracing::warn!(
"⚠️ CORS 允许所有来源 — 仅限开发环境使用!\
生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名"
);
return tower_http::cors::CorsLayer::permissive();
}
}
let allowed: Vec<HeaderValue> = origins