fix(health): P0 安全修复 — SQL注入 + FHIR越权 + OAuth权限 + JWT硬编码
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

C1: action_inbox_service.rs 中 patient_id/user_id 的 format! 拼接改为
    参数化查询 ($2/$3/$4/$5 绑定),消除 SQL 注入风险
C2: fhir/handler.rs 所有患者相关端点强制执行 allowed_patient_ids 范围
    过滤,search 端点用 is_in 过滤,get 端点用 enforce_patient_scope 校验
H5: oauth/handler.rs 5 个管理端点添加 require_permission 校验
M3: oauth/handler.rs 和 middleware.rs 移除 "dev-secret-key" fallback,
    缺少环境变量时启动失败(token)/返回 500(middleware)
This commit is contained in:
iven
2026-05-04 23:09:25 +08:00
parent 95fa09c383
commit 2b90db4028
4 changed files with 151 additions and 12 deletions

View File

@@ -12,6 +12,7 @@ use erp_core::types::TenantContext;
use crate::fhir::converter;
use crate::fhir::types::{category_to_device_types, loinc_to_device_type};
use crate::oauth::middleware::FhirAuthContext;
use crate::state::HealthState;
/// GET /fhir/R4/metadata — FHIR CapabilityStatement
@@ -65,11 +66,38 @@ pub struct FhirSearchParams {
pub status: Option<String>,
}
// ── 患者访问范围辅助 ──────────────────────────────────────────────────
/// 检查单个 patient_id 是否在 OAuth 客户端的允许范围内
fn enforce_patient_scope(fhir_ctx: &FhirAuthContext, patient_id: Uuid) -> Result<(), AppError> {
if let Some(ref allowed) = fhir_ctx.allowed_patient_ids {
if !allowed.iter().any(|id| id == &patient_id.to_string()) {
tracing::warn!(
client_id = %fhir_ctx.client_id,
requested_patient = %patient_id,
"FHIR 客户端尝试访问授权范围外的患者"
);
return Err(AppError::Forbidden("Access denied: patient not in allowed scope".into()));
}
}
Ok(())
}
/// 解析 allowed_patient_ids 为 Uuid 列表
fn resolve_allowed_patient_uuids(fhir_ctx: &FhirAuthContext) -> Option<Vec<Uuid>> {
fhir_ctx.allowed_patient_ids.as_ref().map(|ids| {
ids.iter()
.filter_map(|id| Uuid::parse_str(id).ok())
.collect()
})
}
// ── Patient ────────────────────────────────────────────────────────────
pub async fn search_patients(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.patient.list")?;
@@ -89,6 +117,13 @@ pub async fn search_patients(
query = query.filter(crate::entity::patient::Column::IdNumber.contains(identifier));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::patient::Column::Id.is_in(uuids));
}
}
let limit = params.count.unwrap_or(20).min(100);
let offset = params.offset.unwrap_or(0);
let patients = query
@@ -112,9 +147,11 @@ pub async fn search_patients(
pub async fn get_patient(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.patient.list")?;
enforce_patient_scope(&fhir_ctx, id)?;
let patient = crate::entity::patient::Entity::find_by_id(id)
.one(&state.db)
@@ -133,6 +170,7 @@ pub async fn get_patient(
pub async fn search_observations(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.device-readings.list")?;
@@ -181,6 +219,13 @@ pub async fn search_observations(
}
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids));
}
}
let limit = params.count.unwrap_or(50).min(200);
let readings = query
.order_by_desc(crate::entity::device_readings::Column::MeasuredAt)
@@ -208,6 +253,7 @@ pub async fn search_observations(
pub async fn search_devices(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.devices.list")?;
@@ -222,6 +268,13 @@ pub async fn search_devices(
query = query.filter(crate::entity::patient_devices::Column::PatientId.eq(uid));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids));
}
}
let limit = params.count.unwrap_or(50).min(200);
let devices = query.limit(limit).all(&state.db).await?;
@@ -240,6 +293,7 @@ pub async fn search_devices(
pub async fn get_device(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.devices.list")?;
@@ -253,6 +307,8 @@ pub async fn get_device(
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, device.patient_id)?;
Ok(Json(converter::patient_device_to_fhir(&device)))
}
@@ -312,6 +368,7 @@ pub async fn get_practitioner(
pub async fn search_appointments(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.appointment.list")?;
@@ -329,6 +386,13 @@ pub async fn search_appointments(
query = query.filter(crate::entity::appointment::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids));
}
}
let limit = params.count.unwrap_or(50).min(200);
let appointments = query
.order_by_desc(crate::entity::appointment::Column::AppointmentDate)
@@ -351,6 +415,7 @@ pub async fn search_appointments(
pub async fn get_appointment(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.appointment.list")?;
@@ -364,6 +429,8 @@ pub async fn get_appointment(
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, appointment.patient_id)?;
Ok(Json(converter::appointment_to_fhir(&appointment)))
}
@@ -372,6 +439,7 @@ pub async fn get_appointment(
pub async fn search_diagnostic_reports(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.health-data.list")?;
@@ -392,6 +460,13 @@ pub async fn search_diagnostic_reports(
query = query.filter(crate::entity::lab_report::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids));
}
}
let limit = params.count.unwrap_or(50).min(200);
let reports = query
.order_by_desc(crate::entity::lab_report::Column::ReportDate)
@@ -414,6 +489,7 @@ pub async fn search_diagnostic_reports(
pub async fn get_diagnostic_report(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.health-data.list")?;
@@ -427,6 +503,8 @@ pub async fn get_diagnostic_report(
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, report.patient_id)?;
Ok(Json(converter::lab_report_to_fhir(&report)))
}
@@ -435,6 +513,7 @@ pub async fn get_diagnostic_report(
pub async fn search_encounters(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.consultation.list")?;
@@ -452,6 +531,13 @@ pub async fn search_encounters(
query = query.filter(crate::entity::consultation_session::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids));
}
}
let limit = params.count.unwrap_or(50).min(200);
let sessions = query
.order_by_desc(crate::entity::consultation_session::Column::CreatedAt)
@@ -474,6 +560,7 @@ pub async fn search_encounters(
pub async fn get_encounter(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.consultation.list")?;
@@ -487,6 +574,8 @@ pub async fn get_encounter(
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, session.patient_id)?;
Ok(Json(converter::consultation_to_fhir(&session)))
}
@@ -495,6 +584,7 @@ pub async fn get_encounter(
pub async fn search_tasks(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.follow-up.list")?;
@@ -512,6 +602,13 @@ pub async fn search_tasks(
query = query.filter(crate::entity::follow_up_task::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) {
if !uuids.is_empty() {
query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids));
}
}
let limit = params.count.unwrap_or(50).min(200);
let tasks = query
.order_by_desc(crate::entity::follow_up_task::Column::PlannedDate)
@@ -534,6 +631,7 @@ pub async fn search_tasks(
pub async fn get_task(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.follow-up.list")?;
@@ -547,6 +645,8 @@ pub async fn get_task(
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, task.patient_id)?;
Ok(Json(converter::follow_up_to_fhir(&task)))
}
@@ -556,9 +656,11 @@ pub async fn get_task(
pub async fn patient_everything(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.patient.list")?;
enforce_patient_scope(&fhir_ctx, id)?;
let patient = crate::entity::patient::Entity::find_by_id(id)
.one(&state.db)

View File

@@ -4,6 +4,7 @@ use axum::{
Extension, Json,
};
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::TenantContext;
use uuid::Uuid;
@@ -18,7 +19,7 @@ pub async fn token(
Json(req): Json<TokenRequest>,
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-key".to_string());
.expect("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法签发 OAuth token");
match OAuthService::token(&state.db, &req, &jwt_secret).await {
Ok(resp) => Ok((StatusCode::OK, Json(resp))),
@@ -54,6 +55,7 @@ pub async fn create_client(
Extension(tenant_ctx): Extension<TenantContext>,
Json(req): Json<CreateApiClientReq>,
) -> Result<Json<ApiClientResp>, AppError> {
require_permission(&tenant_ctx, "health.oauth.manage")?;
OAuthService::create_client(&state.db, tenant_ctx.tenant_id, &req, tenant_ctx.user_id)
.await
.map_err(AppError::from)
@@ -65,6 +67,7 @@ pub async fn list_clients(
State(state): State<HealthState>,
Extension(tenant_ctx): Extension<TenantContext>,
) -> Result<Json<Vec<ApiClientListItem>>, AppError> {
require_permission(&tenant_ctx, "health.oauth.list")?;
OAuthService::list_clients(&state.db, tenant_ctx.tenant_id)
.await
.map_err(AppError::from)
@@ -78,6 +81,7 @@ pub async fn update_client(
Path(id): Path<Uuid>,
Json(req): Json<UpdateApiClientReq>,
) -> Result<Json<ApiClientListItem>, AppError> {
require_permission(&tenant_ctx, "health.oauth.manage")?;
OAuthService::update_client(&state.db, tenant_ctx.tenant_id, id, &req, tenant_ctx.user_id)
.await
.map_err(AppError::from)
@@ -90,6 +94,7 @@ pub async fn delete_client(
Extension(tenant_ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
require_permission(&tenant_ctx, "health.oauth.manage")?;
OAuthService::delete_client(&state.db, tenant_ctx.tenant_id, id)
.await
.map_err(AppError::from)?;
@@ -102,6 +107,7 @@ pub async fn regenerate_secret(
Extension(tenant_ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, AppError> {
require_permission(&tenant_ctx, "health.oauth.manage")?;
let (client_id, plain) =
OAuthService::regenerate_secret(&state.db, tenant_ctx.tenant_id, id)
.await

View File

@@ -64,8 +64,24 @@ pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response {
}
};
let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-key".to_string());
let jwt_secret = match std::env::var("ERP__AUTH__JWT_SECRET") {
Ok(secret) => secret,
Err(_) => {
tracing::error!("ERP__AUTH__JWT_SECRET 环境变量未设置 — 无法验证 OAuth token");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"code": "exception",
"diagnostics": "Server configuration error"
}]
})),
)
.into_response();
}
};
let claims = match jsonwebtoken::decode::<ClientCredentialsClaims>(
token,

View File

@@ -268,12 +268,19 @@ pub async fn list_action_items(
let mut segments: Vec<String> = Vec::new();
// patient_id 过滤条件(所有段共用
// patient_id 参数化过滤($2 绑定 patient_id 值,始终传递,条件仅在有值时追加
let patient_filter = match &query.patient_id {
Some(pid) => format!("AND patient_id = '{}'", pid),
Some(_) => "AND patient_id = $2".to_string(),
None => String::new(),
};
// assigned_to 参数化过滤($3 绑定 user_id 值)
let assigned_filter = if filter_by_user {
"AND f.assigned_to = $3".to_string()
} else {
String::new()
};
if include_sug {
segments.push(format!(
r#"
@@ -302,11 +309,6 @@ pub async fn list_action_items(
}
if include_fu {
let assigned_filter = if filter_by_user {
format!("AND f.assigned_to = '{}'", user_id.unwrap())
} else {
String::new()
};
segments.push(format!(
r#"
SELECT f.id, 'followup' AS action_type, 'medium' AS priority_raw,
@@ -331,12 +333,19 @@ 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 内部)
// $4=LIMIT, $5=OFFSET (外层分页)
let patient_val: sea_orm::Value = query.patient_id
.map_or(sea_orm::Value::Uuid(None), |pid| pid.into());
let assigned_val: sea_orm::Value = user_id
.map_or(sea_orm::Value::Uuid(None), |uid| uid.into());
let data_sql = format!(
r#"SELECT * FROM ({union_sql}) sub
ORDER BY
CASE priority_raw WHEN 'high' THEN 1 WHEN 'urgent' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
created_at DESC
LIMIT $2 OFFSET $3"#
LIMIT $4 OFFSET $5"#
);
let count_sql = format!("SELECT COUNT(*) AS cnt FROM ({union_sql}) sub");
@@ -347,6 +356,8 @@ pub async fn list_action_items(
data_sql,
[
tenant_id.into(),
patient_val.clone(),
assigned_val.clone(),
(page_size as i64).into(),
(offset as i64).into(),
],
@@ -360,7 +371,11 @@ pub async fn list_action_items(
})?;
let count_row: Option<CountRow> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(DatabaseBackend::Postgres, count_sql, [tenant_id.into()]),
Statement::from_sql_and_values(
DatabaseBackend::Postgres,
count_sql,
[tenant_id.into(), patient_val, assigned_val],
),
)
.one(db)
.await