fix(security): 文件上传 MIME 白名单 + OAuth JWT 密钥路径统一

P0 #1: 媒体文件上传增加 MIME 类型白名单校验(jpeg/png/gif/webp/svg/mp4/webm/pdf)
       和文件大小限制(10MB),扩展名使用白名单清理防止路径遍历攻击。
P0 #2: OAuth JWT 密钥从环境变量改为 State 注入,消除运行时 env::var 依赖,
       FHIR 路由中间件使用闭包捕获 jwt_secret 保持类型安全。
This commit is contained in:
iven
2026-05-17 12:40:02 +08:00
parent 8d3c5915c9
commit 6841c45846
12 changed files with 180 additions and 43 deletions

View File

@@ -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<Uuid> = 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();

View File

@@ -233,6 +233,70 @@ impl HealthModule {
))
}
pub fn fhir_routes_with_state<S>(jwt_secret: String) -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
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<S>() -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
@@ -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());

View File

@@ -18,18 +18,9 @@ pub async fn token(
State(state): State<HealthState>,
Json(req): Json<TokenRequest>,
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
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,

View File

@@ -39,8 +39,40 @@ pub struct FhirAuthContext {
pub allowed_patient_ids: Option<Vec<String>>,
}
/// 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::<ClientCredentialsClaims>(
token,
&jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()),

View File

@@ -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('/'),

View File

@@ -7,4 +7,6 @@ pub struct HealthState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub crypto: PiiCrypto,
/// JWT 密钥,由 erp-server 在启动时从已验证的环境变量注入
pub jwt_secret: String,
}

View File

@@ -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",

View File

@@ -112,6 +112,7 @@ impl FromRef<AppState> 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(),
}
}
}

View File

@@ -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(),
}
}

View File

@@ -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(),
}
}

View File

@@ -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(),
}
}

View File

@@ -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(),