Skip to main content

yggdrasil/api/
csrf.rs

1//! CSRF 防护:对写请求校验 Origin(回退 Referer)必须等于本站。
2//!
3//! SameSite=Lax 只在顶级 GET 导航时自动带 cookie,对跨站 POST 不带 cookie,
4//! 挡住了大部分经典 CSRF。但存在两个 Lax 覆盖不到的盲区:
5//! 1. 登录 CSRF(攻击者诱导受害者登录攻击者账号,Lax 不阻止「设置」cookie);
6//! 2. 未来若出现 GET 化写接口,Lax 会在顶级 GET 导航时带 cookie。
7//! 因此对所有写请求叠加 Origin 校验作为纵深防御。
8//! 仅在 `feature = "server"` 时编译。
9
10#[cfg(feature = "server")]
11use axum::http::{HeaderMap, Method, Request};
12
13/// 判断请求是否需要 CSRF 校验:非简单方法(POST/PUT/PATCH/DELETE)需要。
14#[cfg(feature = "server")]
15fn is_write_method(method: &Method) -> bool {
16    matches!(
17        method,
18        &Method::POST | &Method::PUT | &Method::PATCH | &Method::DELETE
19    )
20}
21
22/// 启动时检查 `APP_BASE_URL` 是否已设置,未设置则打一条 WARN。
23///
24/// [`trusted_origin`] 在拿不到该变量时会回退到请求 `Host` 头推导本站 origin,
25/// 反向代理后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。
26/// 生产环境应显式设置该变量为站点完整 origin(如 `https://your-domain.example`)。
27///
28/// 本地开发同样会触发(默认不设 `APP_BASE_URL`),代价仅是启动时一条 WARN,
29/// 远小于误判 localhost 的复杂度。与 `image.rs` 的启动告警同范式:一次性 WARN,
30/// 不污染每请求路径。
31#[cfg(feature = "server")]
32pub fn warn_if_app_base_url_unset() {
33    if app_base_url_is_set() {
34        return;
35    }
36    tracing::warn!(
37        "APP_BASE_URL 未设置。CSRF 校验将回退到请求 Host 头推导本站 origin,\
38         反向代理后若 Host 头可被客户端影响存在绕过风险。\
39         生产环境应显式设置为站点完整 origin,如 https://your-domain.example。"
40    );
41}
42
43/// `APP_BASE_URL` 是否已设置为非空值。纯函数,便于测试。
44#[cfg(feature = "server")]
45fn app_base_url_is_set() -> bool {
46    std::env::var("APP_BASE_URL")
47        .ok()
48        .map(|v| !v.trim().is_empty())
49        .unwrap_or(false)
50}
51
52/// 从 `scheme://host[:port][/path][?query]` 提取标准化的 `scheme://host[:port]`,
53/// 端口为默认值(http=80, https=443)时省略。
54///
55/// 不引入 url crate:Origin 头本身就是 `scheme://host[:port]`(无路径),
56/// Referer 需要剥离 path/query,用简单的 split 即可。
57#[cfg(feature = "server")]
58fn normalize_origin(input: &str) -> String {
59    // 取 authority 之前的部分作为 scheme,以及第一个 '/' 之前的部分作为 authority。
60    let (scheme, rest) = match input.split_once("://") {
61        Some(pair) => pair,
62        None => return input.to_string(),
63    };
64    // rest 形如 host[:port]/path?query,去掉首个 '/' 及之后内容。
65    let authority = match rest.split_once('/') {
66        Some((auth, _)) => auth,
67        None => rest,
68    };
69    // 省略默认端口。
70    match authority.rsplit_once(':') {
71        Some((host, port)) if port == "80" || port == "443" => {
72            format!("{}://{}", scheme, host)
73        }
74        _ => format!("{}://{}", scheme, authority),
75    }
76}
77
78/// 从请求头解析来源站点(Origin 优先,回退 Referer)。
79///
80/// 返回标准化的 `scheme://host[:port]`。两者都缺失时返回 None(视为不可信)。
81#[cfg(feature = "server")]
82fn extract_origin(headers: &HeaderMap) -> Option<String> {
83    if let Some(origin) = headers.get(axum::http::header::ORIGIN) {
84        return origin.to_str().ok().map(normalize_origin);
85    }
86    headers
87        .get(axum::http::header::REFERER)
88        .and_then(|v| v.to_str().ok())
89        .map(normalize_origin)
90}
91
92/// 计算本站可信 origin:优先「站点配置 → 安全」面板的 APP_BASE_URL(即时生效),
93/// 否则用请求 Host 头 + `X-Forwarded-Proto`(反代后)或 https 推导。
94///
95/// 返回 None 表示无法确定本站 origin;写请求在这种情况下会被拒绝,
96/// 避免在来源头缺失时把 cookie-auth 请求当作可信请求。
97#[cfg(feature = "server")]
98async fn trusted_origin(headers: &HeaderMap) -> Option<String> {
99    let base = crate::api::settings::runtime_security_settings()
100        .await
101        .app_base_url;
102    if !base.is_empty() {
103        return Some(normalize_origin(&base));
104    }
105    let host = headers.get(axum::http::header::HOST)?.to_str().ok()?;
106    let proto = headers
107        .get("X-Forwarded-Proto")
108        .and_then(|v| v.to_str().ok())
109        .unwrap_or("https");
110    Some(normalize_origin(&format!("{}://{}", proto, host)))
111}
112
113/// 判断写请求的来源是否可信。
114///
115/// cookie-auth 写请求必须同时拥有可确定的本站 origin 与来源头;
116/// 任一缺失都拒绝,避免 `Origin`/`Referer` 被剥离时形成 CSRF fail-open。
117#[cfg(feature = "server")]
118fn origin_matches(trusted: Option<&str>, incoming: Option<&str>) -> bool {
119    matches!((trusted, incoming), (Some(t), Some(o)) if t == o)
120}
121
122/// CSRF 校验中间件。
123///
124/// 对写方法校验请求来源等于本站;不匹配返回 403。GET/OPTIONS 等放行。
125/// 拿不到本站 origin 或请求来源时拒绝写请求。
126#[cfg(feature = "server")]
127pub async fn csrf_middleware(
128    req: Request<axum::body::Body>,
129    next: axum::middleware::Next,
130) -> axum::response::Response {
131    if is_write_method(req.method()) {
132        let headers = req.headers().clone();
133        let trusted = trusted_origin(&headers).await;
134        let incoming = extract_origin(&headers);
135        let ok = origin_matches(trusted.as_deref(), incoming.as_deref());
136        if !ok {
137            return axum::response::Response::builder()
138                .status(axum::http::StatusCode::FORBIDDEN)
139                .body(axum::body::Body::empty())
140                .expect("static forbidden response is always valid");
141        }
142    }
143    next.run(req).await
144}
145
146#[cfg(all(test, feature = "server"))]
147mod tests {
148    use super::*;
149    use axum::http::{HeaderMap, HeaderValue, Method};
150
151    #[test]
152    fn is_write_method_recognizes_writes() {
153        assert!(is_write_method(&Method::POST));
154        assert!(is_write_method(&Method::PUT));
155        assert!(is_write_method(&Method::PATCH));
156        assert!(is_write_method(&Method::DELETE));
157        assert!(!is_write_method(&Method::GET));
158        assert!(!is_write_method(&Method::OPTIONS));
159        assert!(!is_write_method(&Method::HEAD));
160    }
161
162    #[test]
163    fn normalize_strips_path_and_query() {
164        assert_eq!(
165            normalize_origin("https://example.com/a/b?c=1"),
166            "https://example.com"
167        );
168    }
169
170    #[test]
171    fn normalize_preserves_nondefault_port() {
172        assert_eq!(
173            normalize_origin("http://localhost:3000/x"),
174            "http://localhost:3000"
175        );
176    }
177
178    #[test]
179    fn normalize_drops_default_ports() {
180        assert_eq!(
181            normalize_origin("https://example.com:443/path"),
182            "https://example.com"
183        );
184        assert_eq!(
185            normalize_origin("http://example.com:80/path"),
186            "http://example.com"
187        );
188    }
189
190    #[test]
191    fn normalize_keeps_explicit_nondefault_https_port() {
192        assert_eq!(
193            normalize_origin("https://example.com:8443"),
194            "https://example.com:8443"
195        );
196    }
197
198    #[test]
199    fn normalize_plain_origin_no_path() {
200        assert_eq!(
201            normalize_origin("https://example.com"),
202            "https://example.com"
203        );
204    }
205
206    #[test]
207    fn extract_origin_prefers_origin_header() {
208        let mut headers = HeaderMap::new();
209        headers.insert(
210            axum::http::header::ORIGIN,
211            HeaderValue::from_static("https://example.com"),
212        );
213        assert_eq!(
214            extract_origin(&headers),
215            Some("https://example.com".to_string())
216        );
217    }
218
219    #[test]
220    fn extract_origin_falls_back_to_referer() {
221        let mut headers = HeaderMap::new();
222        headers.insert(
223            axum::http::header::REFERER,
224            HeaderValue::from_static("https://example.com/posts/1"),
225        );
226        // Referer 的路径被剥离,只保留 scheme://host。
227        assert_eq!(
228            extract_origin(&headers),
229            Some("https://example.com".to_string())
230        );
231    }
232
233    #[test]
234    fn extract_origin_returns_none_when_both_absent() {
235        let headers = HeaderMap::new();
236        assert_eq!(extract_origin(&headers), None);
237    }
238
239    #[test]
240    fn origin_matches_requires_both_origins() {
241        assert!(origin_matches(
242            Some("https://example.com"),
243            Some("https://example.com")
244        ));
245        assert!(!origin_matches(Some("https://example.com"), None));
246        assert!(!origin_matches(None, Some("https://example.com")));
247        assert!(!origin_matches(
248            Some("https://example.com"),
249            Some("https://evil.example")
250        ));
251    }
252
253    // ── APP_BASE_URL 启动告警 ──────────────────────────────────────
254    // 这些测试读/写 APP_BASE_URL 全局环境变量,用 serial 串行隔离,
255    // 与 rate_limit.rs 的 env 测试同模式(保存 → 设值 → 恢复)。
256
257    #[test]
258    #[serial_test::serial]
259    fn app_base_url_is_set_false_when_unset() {
260        let original = std::env::var("APP_BASE_URL").ok();
261        std::env::remove_var("APP_BASE_URL");
262        assert!(!app_base_url_is_set());
263        restore_env("APP_BASE_URL", original);
264    }
265
266    #[test]
267    #[serial_test::serial]
268    fn app_base_url_is_set_false_when_empty() {
269        let original = std::env::var("APP_BASE_URL").ok();
270        std::env::set_var("APP_BASE_URL", "");
271        assert!(!app_base_url_is_set());
272        restore_env("APP_BASE_URL", original);
273    }
274
275    #[test]
276    #[serial_test::serial]
277    fn app_base_url_is_set_false_when_whitespace_only() {
278        let original = std::env::var("APP_BASE_URL").ok();
279        std::env::set_var("APP_BASE_URL", "   \t  ");
280        assert!(!app_base_url_is_set());
281        restore_env("APP_BASE_URL", original);
282    }
283
284    #[test]
285    #[serial_test::serial]
286    fn app_base_url_is_set_true_when_set() {
287        let original = std::env::var("APP_BASE_URL").ok();
288        std::env::set_var("APP_BASE_URL", "https://example.com");
289        assert!(app_base_url_is_set());
290        restore_env("APP_BASE_URL", original);
291    }
292
293    #[test]
294    #[serial_test::serial]
295    fn app_base_url_is_set_trims_surrounding_whitespace() {
296        let original = std::env::var("APP_BASE_URL").ok();
297        std::env::set_var("APP_BASE_URL", "  https://example.com  ");
298        assert!(app_base_url_is_set());
299        restore_env("APP_BASE_URL", original);
300    }
301
302    /// 恢复环境变量到测试前的状态,避免污染其他测试。
303    fn restore_env(key: &str, original: Option<String>) {
304        match original {
305            Some(value) => std::env::set_var(key, value),
306            None => std::env::remove_var(key),
307        }
308    }
309}