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:
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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('/'),
|
||||
|
||||
@@ -7,4 +7,6 @@ pub struct HealthState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub crypto: PiiCrypto,
|
||||
/// JWT 密钥,由 erp-server 在启动时从已验证的环境变量注入
|
||||
pub jwt_secret: String,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user