feat(csrf): warn at startup when APP_BASE_URL is unset

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 状态或刷屏风险。
This commit is contained in:
xfy 2026-06-29 11:02:58 +08:00
parent c237007111
commit 74f8c212f6
2 changed files with 91 additions and 0 deletions

View File

@ -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<String>) {
match original {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}

View File

@ -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。