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:
parent
c237007111
commit
74f8c212f6
@ -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]`,
|
/// 从 `scheme://host[:port][/path][?query]` 提取标准化的 `scheme://host[:port]`,
|
||||||
/// 端口为默认值(http=80, https=443)时省略。
|
/// 端口为默认值(http=80, https=443)时省略。
|
||||||
///
|
///
|
||||||
@ -197,4 +227,61 @@ mod tests {
|
|||||||
let headers = HeaderMap::new();
|
let headers = HeaderMap::new();
|
||||||
assert_eq!(extract_origin(&headers), None);
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -224,6 +224,10 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提醒部署者显式设置 APP_BASE_URL:未设置时 CSRF 会回退到 Host 头,
|
||||||
|
// 反向代理后存在绕过风险(本地 localhost 开发会静默,不打日志)。
|
||||||
|
api::csrf::warn_if_app_base_url_unset();
|
||||||
|
|
||||||
// 启动前执行数据库迁移。阻塞:完成前不监听端口。
|
// 启动前执行数据库迁移。阻塞:完成前不监听端口。
|
||||||
// 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
|
// 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
|
||||||
// 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
|
// 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user