diff --git a/AGENTS.md b/AGENTS.md index c1c67fb..f7c2b33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ Session / security tuning: COOKIE_SECURE=false # set true/1/yes to add Secure flag to session cookie TRUSTED_PROXY_COUNT=0 # number of reverse proxies in front of the app; used to extract real client IP from X-Forwarded-For APP_BASE_URL= # e.g. https://your-domain.example — trusted origin for CSRF checks on write requests; unset falls back to Host header + X-Forwarded-Proto +EXPOSE_VERSION_HEADERS=true # 附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git 响应头;设 0/false/no 关闭(不向外部暴露版本/commit 时) ``` ## Architecture: Conditional Compilation diff --git a/src/main.rs b/src/main.rs index 7022713..05ea3b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -417,6 +417,18 @@ fn main() { .invalidate_after(std::time::Duration::from_secs(ssr_cache_secs)), ); + // 版本响应头开关:默认开启。设 0/false/no 关闭(注重安全、不想对外暴露版本/commit 时)。 + // bool 解析约定与 COOKIE_SECURE 一致(matches "1"/"true"/"yes");这里取反为 + // "非 false 值即开",使默认行为(unwrap_or(true))对应「暴露」。 + let expose_version_headers = std::env::var("EXPOSE_VERSION_HEADERS") + .ok() + .map(|v| !matches!(v.as_str(), "0" | "false" | "no")) + .unwrap_or(true); + tracing::info!( + expose_version_headers, + "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git)" + ); + // SSR 世代号中间件:把当前全局世代号注入请求扩展,并对 GET 请求的 // 响应附加 `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键 // 预留的钩子;目前主要提供可观测性,不会实际失效 SSR 缓存。 @@ -441,6 +453,40 @@ fn main() { response } + // 版本头中间件:为所有响应附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git, + // 数据源与启动日志 log_build_info() 同源(crate::build_info::BUILD_INFO)。 + // 挂在最终合并 router 的最外层(见下方 Ok(router) 前),因此连 /healthz、 + // /uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应都会带头, + // 探测价值最大。受 EXPOSE_VERSION_HEADERS 控制(默认 true)。 + async fn version_headers_middleware( + req: axum::http::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + let mut response = next.run(req).await; + let h = response.headers_mut(); + // Server 头:产品名/版本,遵循 "Server: product/version" 习惯。 + h.insert( + axum::http::header::SERVER, + axum::http::HeaderValue::from_str(&format!( + "yggdrasil/{}", + crate::build_info::BUILD_INFO.version + )) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")), + ); + // X-Yggdrasil-Version:Cargo.toml 版本号。 + h.insert( + axum::http::header::HeaderName::from_static("x-yggdrasil-version"), + axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version), + ); + // X-Yggdrasil-Git:git describe(版本+提交数+短hash+脏标记)。 + h.insert( + axum::http::header::HeaderName::from_static("x-yggdrasil-git"), + axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")), + ); + response + } + // 自定义 API 路由:图片上传(大文件,需要更长超时) // CSRF 校验置于最外层,先拦截非法来源再做超时/限体。 let upload_route = axum::Router::new() @@ -533,6 +579,14 @@ fn main() { .merge(app_routes) .merge(static_routes); + // 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、 + // 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。 + let router = if expose_version_headers { + router.layer(axum::middleware::from_fn(version_headers_middleware)) + } else { + router + }; + Ok(router) }); }