diff --git a/crates/erp-health/src/handler/media_handler.rs b/crates/erp-health/src/handler/media_handler.rs index a989c1d..78ae129 100644 --- a/crates/erp-health/src/handler/media_handler.rs +++ b/crates/erp-health/src/handler/media_handler.rs @@ -15,6 +15,34 @@ use crate::dto::media_dto::{ use crate::service::media_service; use crate::state::HealthState; +// --------------------------------------------------------------------------- +// 上传安全验证 +// --------------------------------------------------------------------------- + +/// 允许上传的 MIME 类型白名单 +const ALLOWED_MIME_TYPES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "video/mp4", + "video/webm", + "application/pdf", +]; + +/// 验证上传文件的 MIME 类型是否在白名单中 +fn validate_upload_mime(content_type: &str) -> Result<(), AppError> { + if !ALLOWED_MIME_TYPES.contains(&content_type) { + return Err(AppError::Validation(format!( + "不支持的文件类型: {}(允许: {})", + content_type, + ALLOWED_MIME_TYPES.join(", ") + ))); + } + Ok(()) +} + // --------------------------------------------------------------------------- // 本地请求结构体 // --------------------------------------------------------------------------- @@ -55,6 +83,9 @@ where { require_permission(&ctx, "health.media.manage")?; + // 文件大小限制: 10MB + const MAX_UPLOAD_SIZE: usize = 10 * 1024 * 1024; + let mut file_data = None; let mut folder_id: Option = None; let mut is_public = false; @@ -73,12 +104,20 @@ where .content_type() .unwrap_or("application/octet-stream") .to_string(); - file_data = Some( - field - .bytes() - .await - .map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?, - ); + // MIME 类型白名单校验 + validate_upload_mime(&content_type)?; + let data = field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?; + // 文件大小校验 + if data.len() > MAX_UPLOAD_SIZE { + return Err(AppError::Validation(format!( + "文件大小超过限制 (最大 {}MB)", + MAX_UPLOAD_SIZE / 1024 / 1024 + ))); + } + file_data = Some(data); } "folder_id" => { let text = field.text().await.unwrap_or_default(); diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index ccca0a1..a1907d4 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -233,6 +233,70 @@ impl HealthModule { )) } + pub fn fhir_routes_with_state(jwt_secret: String) -> Router + where + crate::state::HealthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + use crate::fhir::handler as fhir; + + Router::new() + .route( + "/R4/metadata", + axum::routing::get(fhir::capability_statement), + ) + .route("/R4/Patient", axum::routing::get(fhir::search_patients)) + .route("/R4/Patient/{id}", axum::routing::get(fhir::get_patient)) + .route( + "/R4/Observation", + axum::routing::get(fhir::search_observations), + ) + .route("/R4/Device", axum::routing::get(fhir::search_devices)) + .route("/R4/Device/{id}", axum::routing::get(fhir::get_device)) + .route( + "/R4/Practitioner", + axum::routing::get(fhir::search_practitioners), + ) + .route( + "/R4/Practitioner/{id}", + axum::routing::get(fhir::get_practitioner), + ) + .route( + "/R4/Appointment", + axum::routing::get(fhir::search_appointments), + ) + .route( + "/R4/Appointment/{id}", + axum::routing::get(fhir::get_appointment), + ) + .route( + "/R4/DiagnosticReport", + axum::routing::get(fhir::search_diagnostic_reports), + ) + .route( + "/R4/DiagnosticReport/{id}", + axum::routing::get(fhir::get_diagnostic_report), + ) + .route("/R4/Encounter", axum::routing::get(fhir::search_encounters)) + .route( + "/R4/Encounter/{id}", + axum::routing::get(fhir::get_encounter), + ) + .route("/R4/Task", axum::routing::get(fhir::search_tasks)) + .route("/R4/Task/{id}", axum::routing::get(fhir::get_task)) + .route( + "/R4/Patient/{id}/$everything", + axum::routing::get(fhir::patient_everything), + ) + .layer(axum::middleware::from_fn(move |req, next| { + let secret = jwt_secret.clone(); + async move { + crate::oauth::middleware::oauth_auth_middleware_with_secret(&secret, req, next) + .await + } + })) + } + pub fn protected_routes() -> Router where crate::state::HealthState: axum::extract::FromRef, @@ -323,6 +387,7 @@ impl ErpModule for HealthModule { db: ctx.db.clone(), event_bus: ctx.event_bus.clone(), crypto, + jwt_secret: "test-jwt-secret".to_string(), }; crate::event::register_handlers_with_state(state.clone()); diff --git a/crates/erp-health/src/oauth/handler.rs b/crates/erp-health/src/oauth/handler.rs index 90975ec..93d23fa 100644 --- a/crates/erp-health/src/oauth/handler.rs +++ b/crates/erp-health/src/oauth/handler.rs @@ -18,18 +18,9 @@ pub async fn token( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { - 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("服务配置错误")), - )); - } - }; + let jwt_secret = &state.jwt_secret; - match OAuthService::token(&state.db, &req, &jwt_secret).await { + match OAuthService::token(&state.db, &req, jwt_secret).await { Ok(resp) => Ok((StatusCode::OK, Json(resp))), Err(OAuthError::InvalidClient | OAuthError::ClientInactive) => Err(( StatusCode::UNAUTHORIZED, diff --git a/crates/erp-health/src/oauth/middleware.rs b/crates/erp-health/src/oauth/middleware.rs index 60e6b08..d66c9ad 100644 --- a/crates/erp-health/src/oauth/middleware.rs +++ b/crates/erp-health/src/oauth/middleware.rs @@ -39,8 +39,40 @@ pub struct FhirAuthContext { pub allowed_patient_ids: Option>, } -/// FHIR OAuth 认证中间件 +/// FHIR OAuth 认证中间件(旧版,兼容过渡) pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response { + 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(); + } + }; + verify_oauth_token(&jwt_secret, request, next).await +} + +/// FHIR OAuth 认证中间件(新版,通过闭包捕获已验证的 JWT 密钥) +pub async fn oauth_auth_middleware_with_secret( + jwt_secret: &str, + request: Request, + next: Next, +) -> Response { + verify_oauth_token(jwt_secret, request, next).await +} + +/// 共享的 OAuth token 验证逻辑 +async fn verify_oauth_token(jwt_secret: &str, request: Request, next: Next) -> Response { let auth_header = request .headers() .get("Authorization") @@ -64,25 +96,6 @@ pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response { } }; - 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::( token, &jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()), diff --git a/crates/erp-health/src/service/media_service.rs b/crates/erp-health/src/service/media_service.rs index 2b08bb8..1a75b3e 100644 --- a/crates/erp-health/src/service/media_service.rs +++ b/crates/erp-health/src/service/media_service.rs @@ -16,6 +16,28 @@ use crate::entity::{banner, media_folder, media_item}; use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; +// --------------------------------------------------------------------------- +// 上传安全工具 +// --------------------------------------------------------------------------- + +/// 允许的文件扩展名白名单 +const ALLOWED_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "pdf", +]; + +/// 从文件名中提取并验证扩展名,防止路径遍历攻击 +fn sanitize_extension(filename: &str) -> String { + let ext = Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin") + .to_lowercase(); + match ALLOWED_EXTENSIONS.contains(&ext.as_str()) { + true => ext, + false => "bin".to_string(), + } +} + // --------------------------------------------------------------------------- // 媒体文件查询 // --------------------------------------------------------------------------- @@ -99,10 +121,8 @@ pub async fn upload_media( let now = Utc::now(); // 构造存储路径: {upload_dir}/{tenant_id}/{uuid}.ext - let ext = Path::new(filename) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("bin"); + // 扩展名白名单验证:仅保留安全字符,防止路径遍历 + let ext = sanitize_extension(filename); let relative_path = format!( "{}/{}/{}.{}", upload_dir.trim_end_matches('/'), diff --git a/crates/erp-health/src/state.rs b/crates/erp-health/src/state.rs index 2dbe673..2a5144d 100644 --- a/crates/erp-health/src/state.rs +++ b/crates/erp-health/src/state.rs @@ -7,4 +7,6 @@ pub struct HealthState { pub db: DatabaseConnection, pub event_bus: EventBus, pub crypto: PiiCrypto, + /// JWT 密钥,由 erp-server 在启动时从已验证的环境变量注入 + pub jwt_secret: String, } diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 723cd18..400f8e4 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -723,8 +723,9 @@ async fn main() -> anyhow::Result<()> { )) .layer({ let db = state.db.clone(); + let jwt_secret_for_auth = jwt_secret.clone(); axum_middleware::from_fn(move |req, next| { - let secret = jwt_secret.clone(); + let secret = jwt_secret_for_auth.clone(); let db = db.clone(); async move { jwt_auth_middleware_fn(secret, Some(db), req, next).await } }) @@ -749,7 +750,8 @@ async fn main() -> anyhow::Result<()> { let secret = secret_for_uploads.clone(); async move { upload_auth_middleware(secret, req, next).await } })); - let fhir_routes = erp_health::HealthModule::fhir_routes().with_state(state.clone()); + let fhir_routes = erp_health::HealthModule::fhir_routes_with_state(jwt_secret.clone()) + .with_state(state.clone()); let app = Router::new() .nest( "/api/v1", diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index af00d9b..f7e9948 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -112,6 +112,7 @@ impl FromRef for erp_health::HealthState { db: state.db.clone(), event_bus: state.event_bus.clone(), crypto: state.pii_crypto.clone(), + jwt_secret: state.config.jwt.secret.clone(), } } } diff --git a/crates/erp-server/tests/integration/health_appointment_tests.rs b/crates/erp-server/tests/integration/health_appointment_tests.rs index 68771be..ebc847e 100644 --- a/crates/erp-server/tests/integration/health_appointment_tests.rs +++ b/crates/erp-server/tests/integration/health_appointment_tests.rs @@ -20,6 +20,7 @@ fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState { db: db.clone(), event_bus: EventBus::new(100), crypto: PiiCrypto::dev_default(), + jwt_secret: "test-jwt-secret".to_string(), } } diff --git a/crates/erp-server/tests/integration/health_patient_tests.rs b/crates/erp-server/tests/integration/health_patient_tests.rs index 578487a..8256caf 100644 --- a/crates/erp-server/tests/integration/health_patient_tests.rs +++ b/crates/erp-server/tests/integration/health_patient_tests.rs @@ -17,6 +17,7 @@ fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState { db: db.clone(), event_bus: EventBus::new(100), crypto: PiiCrypto::dev_default(), + jwt_secret: "test-jwt-secret".to_string(), } } diff --git a/crates/erp-server/tests/integration/health_pii_encryption_tests.rs b/crates/erp-server/tests/integration/health_pii_encryption_tests.rs index 82e1b4e..8f1c21a 100644 --- a/crates/erp-server/tests/integration/health_pii_encryption_tests.rs +++ b/crates/erp-server/tests/integration/health_pii_encryption_tests.rs @@ -24,6 +24,7 @@ fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState { db: db.clone(), event_bus: EventBus::new(100), crypto: PiiCrypto::dev_default(), + jwt_secret: "test-jwt-secret".to_string(), } } diff --git a/crates/erp-server/tests/integration/test_fixture.rs b/crates/erp-server/tests/integration/test_fixture.rs index 6c71f7c..a8fc7c8 100644 --- a/crates/erp-server/tests/integration/test_fixture.rs +++ b/crates/erp-server/tests/integration/test_fixture.rs @@ -26,6 +26,7 @@ impl TestApp { db: test_db.db().clone(), event_bus: EventBus::new(100), crypto: PiiCrypto::dev_default(), + jwt_secret: "test-jwt-secret-for-integration-tests".to_string(), }; let dialysis_state = DialysisState { db: test_db.db().clone(),