From 74f8c212f690c79d7bb1332f68c13b137df7b7eb Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 29 Jun 2026 11:02:58 +0800 Subject: [PATCH] feat(csrf): warn at startup when APP_BASE_URL is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APP_BASE_URL 未设置时 trusted_origin 回退到请求 Host 头推导本站 origin, 反向代理后若 Host 头可被客户端影响存在 CSRF 绕过风险。.env.example 虽然 警告了,但默认值就是空——'cp .env.example .env 后忘改' 是最常见的部署失误。 加启动时一次性 WARN(与 image.rs 的启动告警同范式): - warn_if_app_base_url_unset() 在 main.rs 启动序列调用,与 validate_database_url 等配置告警归在一处 - 抽出纯函数 app_base_url_is_set() 承载判断逻辑,便于测试,打日志副作用与之解耦 - 5 个单测覆盖 unset/empty/whitespace/set/trim 边界 每请求路径的 trusted_origin 保持纯函数不变,不引入 dedup 状态或刷屏风险。 --- src/api/csrf.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 +++ 2 files changed, 91 insertions(+) diff --git a/src/api/csrf.rs b/src/api/csrf.rs index 926d646..00cb59d 100644 --- a/src/api/csrf.rs +++ b/src/api/csrf.rs @@ -19,6 +19,36 @@ fn is_write_method(method: &Method) -> bool { ) } +/// 启动时检查 `APP_BASE_URL` 是否已设置,未设置则打一条 WARN。 +/// +/// [`trusted_origin`] 在拿不到该变量时会回退到请求 `Host` 头推导本站 origin, +/// 反向代理后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。 +/// 生产环境应显式设置该变量为站点完整 origin(如 `https://your-domain.example`)。 +/// +/// 本地开发同样会触发(默认不设 `APP_BASE_URL`),代价仅是启动时一条 WARN, +/// 远小于误判 localhost 的复杂度。与 `image.rs` 的启动告警同范式:一次性 WARN, +/// 不污染每请求路径。 +#[cfg(feature = "server")] +pub fn warn_if_app_base_url_unset() { + if app_base_url_is_set() { + return; + } + tracing::warn!( + "APP_BASE_URL 未设置。CSRF 校验将回退到请求 Host 头推导本站 origin,\ + 反向代理后若 Host 头可被客户端影响存在绕过风险。\ + 生产环境应显式设置为站点完整 origin,如 https://your-domain.example。" + ); +} + +/// `APP_BASE_URL` 是否已设置为非空值。纯函数,便于测试。 +#[cfg(feature = "server")] +fn app_base_url_is_set() -> bool { + std::env::var("APP_BASE_URL") + .ok() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + /// 从 `scheme://host[:port][/path][?query]` 提取标准化的 `scheme://host[:port]`, /// 端口为默认值(http=80, https=443)时省略。 /// @@ -197,4 +227,61 @@ mod tests { let headers = HeaderMap::new(); assert_eq!(extract_origin(&headers), None); } + + // ── APP_BASE_URL 启动告警 ────────────────────────────────────── + // 这些测试读/写 APP_BASE_URL 全局环境变量,用 serial 串行隔离, + // 与 rate_limit.rs 的 env 测试同模式(保存 → 设值 → 恢复)。 + + #[test] + #[serial_test::serial] + fn app_base_url_is_set_false_when_unset() { + let original = std::env::var("APP_BASE_URL").ok(); + std::env::remove_var("APP_BASE_URL"); + assert!(!app_base_url_is_set()); + restore_env("APP_BASE_URL", original); + } + + #[test] + #[serial_test::serial] + fn app_base_url_is_set_false_when_empty() { + let original = std::env::var("APP_BASE_URL").ok(); + std::env::set_var("APP_BASE_URL", ""); + assert!(!app_base_url_is_set()); + restore_env("APP_BASE_URL", original); + } + + #[test] + #[serial_test::serial] + fn app_base_url_is_set_false_when_whitespace_only() { + let original = std::env::var("APP_BASE_URL").ok(); + std::env::set_var("APP_BASE_URL", " \t "); + assert!(!app_base_url_is_set()); + restore_env("APP_BASE_URL", original); + } + + #[test] + #[serial_test::serial] + fn app_base_url_is_set_true_when_set() { + let original = std::env::var("APP_BASE_URL").ok(); + std::env::set_var("APP_BASE_URL", "https://example.com"); + assert!(app_base_url_is_set()); + restore_env("APP_BASE_URL", original); + } + + #[test] + #[serial_test::serial] + fn app_base_url_is_set_trims_surrounding_whitespace() { + let original = std::env::var("APP_BASE_URL").ok(); + std::env::set_var("APP_BASE_URL", " https://example.com "); + assert!(app_base_url_is_set()); + restore_env("APP_BASE_URL", original); + } + + /// 恢复环境变量到测试前的状态,避免污染其他测试。 + fn restore_env(key: &str, original: Option) { + match original { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } } diff --git a/src/main.rs b/src/main.rs index 732c8a3..8dd9c84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -224,6 +224,10 @@ fn main() { std::process::exit(1); } + // 提醒部署者显式设置 APP_BASE_URL:未设置时 CSRF 会回退到 Host 头, + // 反向代理后存在绕过风险(本地 localhost 开发会静默,不打日志)。 + api::csrf::warn_if_app_base_url_unset(); + // 启动前执行数据库迁移。阻塞:完成前不监听端口。 // 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。 // 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。