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::service::media_service;
|
||||||
use crate::state::HealthState;
|
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")?;
|
require_permission(&ctx, "health.media.manage")?;
|
||||||
|
|
||||||
|
// 文件大小限制: 10MB
|
||||||
|
const MAX_UPLOAD_SIZE: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
let mut file_data = None;
|
let mut file_data = None;
|
||||||
let mut folder_id: Option<Uuid> = None;
|
let mut folder_id: Option<Uuid> = None;
|
||||||
let mut is_public = false;
|
let mut is_public = false;
|
||||||
@@ -73,12 +104,20 @@ where
|
|||||||
.content_type()
|
.content_type()
|
||||||
.unwrap_or("application/octet-stream")
|
.unwrap_or("application/octet-stream")
|
||||||
.to_string();
|
.to_string();
|
||||||
file_data = Some(
|
// MIME 类型白名单校验
|
||||||
field
|
validate_upload_mime(&content_type)?;
|
||||||
.bytes()
|
let data = field
|
||||||
.await
|
.bytes()
|
||||||
.map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?,
|
.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" => {
|
"folder_id" => {
|
||||||
let text = field.text().await.unwrap_or_default();
|
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>
|
pub fn protected_routes<S>() -> Router<S>
|
||||||
where
|
where
|
||||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||||
@@ -323,6 +387,7 @@ impl ErpModule for HealthModule {
|
|||||||
db: ctx.db.clone(),
|
db: ctx.db.clone(),
|
||||||
event_bus: ctx.event_bus.clone(),
|
event_bus: ctx.event_bus.clone(),
|
||||||
crypto,
|
crypto,
|
||||||
|
jwt_secret: "test-jwt-secret".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
crate::event::register_handlers_with_state(state.clone());
|
crate::event::register_handlers_with_state(state.clone());
|
||||||
|
|||||||
@@ -18,18 +18,9 @@ pub async fn token(
|
|||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Json(req): Json<TokenRequest>,
|
Json(req): Json<TokenRequest>,
|
||||||
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
|
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
|
||||||
let jwt_secret = match std::env::var("ERP__AUTH__JWT_SECRET") {
|
let jwt_secret = &state.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 {
|
match OAuthService::token(&state.db, &req, jwt_secret).await {
|
||||||
Ok(resp) => Ok((StatusCode::OK, Json(resp))),
|
Ok(resp) => Ok((StatusCode::OK, Json(resp))),
|
||||||
Err(OAuthError::InvalidClient | OAuthError::ClientInactive) => Err((
|
Err(OAuthError::InvalidClient | OAuthError::ClientInactive) => Err((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
|
|||||||
@@ -39,8 +39,40 @@ pub struct FhirAuthContext {
|
|||||||
pub allowed_patient_ids: Option<Vec<String>>,
|
pub allowed_patient_ids: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FHIR OAuth 认证中间件
|
/// FHIR OAuth 认证中间件(旧版,兼容过渡)
|
||||||
pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response {
|
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
|
let auth_header = request
|
||||||
.headers()
|
.headers()
|
||||||
.get("Authorization")
|
.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>(
|
let claims = match jsonwebtoken::decode::<ClientCredentialsClaims>(
|
||||||
token,
|
token,
|
||||||
&jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()),
|
&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::error::{HealthError, HealthResult};
|
||||||
use crate::state::HealthState;
|
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();
|
let now = Utc::now();
|
||||||
|
|
||||||
// 构造存储路径: {upload_dir}/{tenant_id}/{uuid}.ext
|
// 构造存储路径: {upload_dir}/{tenant_id}/{uuid}.ext
|
||||||
let ext = Path::new(filename)
|
// 扩展名白名单验证:仅保留安全字符,防止路径遍历
|
||||||
.extension()
|
let ext = sanitize_extension(filename);
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("bin");
|
|
||||||
let relative_path = format!(
|
let relative_path = format!(
|
||||||
"{}/{}/{}.{}",
|
"{}/{}/{}.{}",
|
||||||
upload_dir.trim_end_matches('/'),
|
upload_dir.trim_end_matches('/'),
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ pub struct HealthState {
|
|||||||
pub db: DatabaseConnection,
|
pub db: DatabaseConnection,
|
||||||
pub event_bus: EventBus,
|
pub event_bus: EventBus,
|
||||||
pub crypto: PiiCrypto,
|
pub crypto: PiiCrypto,
|
||||||
|
/// JWT 密钥,由 erp-server 在启动时从已验证的环境变量注入
|
||||||
|
pub jwt_secret: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,8 +723,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
))
|
))
|
||||||
.layer({
|
.layer({
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
let jwt_secret_for_auth = jwt_secret.clone();
|
||||||
axum_middleware::from_fn(move |req, next| {
|
axum_middleware::from_fn(move |req, next| {
|
||||||
let secret = jwt_secret.clone();
|
let secret = jwt_secret_for_auth.clone();
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
async move { jwt_auth_middleware_fn(secret, Some(db), req, next).await }
|
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();
|
let secret = secret_for_uploads.clone();
|
||||||
async move { upload_auth_middleware(secret, req, next).await }
|
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()
|
let app = Router::new()
|
||||||
.nest(
|
.nest(
|
||||||
"/api/v1",
|
"/api/v1",
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ impl FromRef<AppState> for erp_health::HealthState {
|
|||||||
db: state.db.clone(),
|
db: state.db.clone(),
|
||||||
event_bus: state.event_bus.clone(),
|
event_bus: state.event_bus.clone(),
|
||||||
crypto: state.pii_crypto.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(),
|
db: db.clone(),
|
||||||
event_bus: EventBus::new(100),
|
event_bus: EventBus::new(100),
|
||||||
crypto: PiiCrypto::dev_default(),
|
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(),
|
db: db.clone(),
|
||||||
event_bus: EventBus::new(100),
|
event_bus: EventBus::new(100),
|
||||||
crypto: PiiCrypto::dev_default(),
|
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(),
|
db: db.clone(),
|
||||||
event_bus: EventBus::new(100),
|
event_bus: EventBus::new(100),
|
||||||
crypto: PiiCrypto::dev_default(),
|
crypto: PiiCrypto::dev_default(),
|
||||||
|
jwt_secret: "test-jwt-secret".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ impl TestApp {
|
|||||||
db: test_db.db().clone(),
|
db: test_db.db().clone(),
|
||||||
event_bus: EventBus::new(100),
|
event_bus: EventBus::new(100),
|
||||||
crypto: PiiCrypto::dev_default(),
|
crypto: PiiCrypto::dev_default(),
|
||||||
|
jwt_secret: "test-jwt-secret-for-integration-tests".to_string(),
|
||||||
};
|
};
|
||||||
let dialysis_state = DialysisState {
|
let dialysis_state = DialysisState {
|
||||||
db: test_db.db().clone(),
|
db: test_db.db().clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user