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 会带 cookie,GET 下载类接口(数据导出)可被跨站触发。
7//! 因此对所有写请求叠加 Origin 校验作为纵深防御;GET 下载类接口另挂
8//! [`crate::api::csrf::same_origin_read_middleware`]。
9//! 仅在 `feature = "server"` 时编译。
10
11#[cfg(feature = "server")]
12use axum::http::{HeaderMap, Method, Request};
13
14/// 判断请求是否需要 CSRF 校验:非简单方法(POST/PUT/PATCH/DELETE)需要。
15#[cfg(feature = "server")]
16fn is_write_method(method: &Method) -> bool {
17    matches!(
18        method,
19        &Method::POST | &Method::PUT | &Method::PATCH | &Method::DELETE
20    )
21}
22
23/// 启动时检查 `APP_BASE_URL` 是否已设置,未设置则打一条 WARN。
24///
25/// 未配置时 [`trusted_source`] 回退为“来源主机等于请求 `Host` 头”,
26/// 反向代理后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。
27/// 生产环境应显式设置该变量为站点完整 origin(如 `https://your-domain.example`)。
28///
29/// 本地开发同样会触发(默认不设 `APP_BASE_URL`),代价仅是启动时一条 WARN。
30/// 与 `image.rs` 的启动告警同范式:一次性 WARN,不污染每请求路径。
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 头的主机,\
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    format!("{}://{}", scheme, normalize_authority(rest))
65}
66
67/// 从 `host[:port][/path]` 取出 `host[:port]`,省略默认端口(80/443)。
68#[cfg(feature = "server")]
69fn normalize_authority(input: &str) -> &str {
70    // input 形如 host[:port]/path?query,去掉首个 '/' 及之后内容。
71    let authority = match input.split_once('/') {
72        Some((auth, _)) => auth,
73        None => input,
74    };
75    match authority.rsplit_once(':') {
76        Some((host, port)) if port == "80" || port == "443" => host,
77        _ => authority,
78    }
79}
80
81/// 从请求头解析来源站点(Origin 优先,回退 Referer)。
82///
83/// 返回标准化的 `scheme://host[:port]`。两者都缺失时返回 None(视为不可信)。
84#[cfg(feature = "server")]
85fn extract_origin(headers: &HeaderMap) -> Option<String> {
86    if let Some(origin) = headers.get(axum::http::header::ORIGIN) {
87        return origin.to_str().ok().map(normalize_origin);
88    }
89    headers
90        .get(axum::http::header::REFERER)
91        .and_then(|v| v.to_str().ok())
92        .map(normalize_origin)
93}
94
95/// 本站来源的判定依据。
96#[cfg(feature = "server")]
97enum TrustedSource {
98    /// 配置了 APP_BASE_URL:来源必须与它的 origin 完全相同(含协议)。
99    Origin(String),
100    /// 未配置:来源的 `host[:port]` 必须等于请求 `Host` 头,协议不限。
101    /// 本地 http 开发无需配置即可使用,localhost 与 127.0.0.1 各自成立;
102    /// 跨站请求的来源主机不同,照样被拒。
103    Host(String),
104}
105
106/// 计算本站来源:优先「站点配置 → 安全」面板的 APP_BASE_URL(即时生效),
107/// 否则取请求 Host 头。
108///
109/// 返回 None 表示无法确定本站来源;写请求在这种情况下会被拒绝,
110/// 避免在来源头缺失时把 cookie-auth 请求当作可信请求。
111#[cfg(feature = "server")]
112async fn trusted_source(headers: &HeaderMap) -> Option<TrustedSource> {
113    let base = crate::api::settings::runtime_security_settings()
114        .await
115        .app_base_url;
116    if !base.is_empty() {
117        return Some(TrustedSource::Origin(normalize_origin(&base)));
118    }
119    let host = headers.get(axum::http::header::HOST)?.to_str().ok()?;
120    Some(TrustedSource::Host(normalize_authority(host).to_string()))
121}
122
123/// 判断写请求的来源是否可信。
124///
125/// cookie-auth 写请求必须同时拥有可确定的本站来源与来源头;
126/// 任一缺失都拒绝,避免 `Origin`/`Referer` 被剥离时形成 CSRF fail-open。
127#[cfg(feature = "server")]
128fn origin_matches(trusted: Option<&TrustedSource>, incoming: Option<&str>) -> bool {
129    match (trusted, incoming) {
130        (Some(TrustedSource::Origin(origin)), Some(incoming)) => origin == incoming,
131        (Some(TrustedSource::Host(host)), Some(incoming)) => incoming
132            .strip_prefix("http://")
133            .or_else(|| incoming.strip_prefix("https://"))
134            .is_some_and(|authority| !host.is_empty() && authority.eq_ignore_ascii_case(host)),
135        _ => false,
136    }
137}
138
139/// 从 Fetch Metadata 判断请求是否由本站页面发起。
140///
141/// 浏览器的顶级 GET 导航(`window.open`、`<a href>`)不带 `Origin`,只能靠
142/// `Sec-Fetch-Site`:仅 `same-origin` 可信;`same-site` 包含兄弟子域,`cross-site`
143/// 是跨站,`none` 是地址栏/书签直开,本站 UI 不会产生,一律拒绝。
144/// 返回 None 表示请求不带该头(旧浏览器或非浏览器客户端)。
145#[cfg(feature = "server")]
146fn fetch_site_is_same_origin(headers: &HeaderMap) -> Option<bool> {
147    headers
148        .get("sec-fetch-site")
149        .map(|v| v.as_bytes() == b"same-origin")
150}
151
152#[cfg(feature = "server")]
153fn forbidden() -> axum::response::Response {
154    axum::response::Response::builder()
155        .status(axum::http::StatusCode::FORBIDDEN)
156        .body(axum::body::Body::empty())
157        .expect("static forbidden response is always valid")
158}
159
160/// CSRF 校验中间件。
161///
162/// 对写方法校验请求来源等于本站;不匹配返回 403。GET/OPTIONS 等放行。
163/// 拿不到本站 origin 或请求来源时拒绝写请求。
164#[cfg(feature = "server")]
165pub async fn csrf_middleware(
166    req: Request<axum::body::Body>,
167    next: axum::middleware::Next,
168) -> axum::response::Response {
169    if is_write_method(req.method()) {
170        let headers = req.headers().clone();
171        let trusted = trusted_source(&headers).await;
172        let incoming = extract_origin(&headers);
173        let ok = origin_matches(trusted.as_ref(), incoming.as_deref());
174        if !ok {
175            return forbidden();
176        }
177    }
178    next.run(req).await
179}
180
181/// GET 下载类接口的同源校验中间件:拒绝跨站触发的顶级导航。
182///
183/// 优先看 `Sec-Fetch-Site`;不带该头时回退到与写请求相同的 Origin/Referer 校验,
184/// 来源缺失同样拒绝。只挂在需要的路由上,不影响普通页面 GET。
185#[cfg(feature = "server")]
186pub async fn same_origin_read_middleware(
187    req: Request<axum::body::Body>,
188    next: axum::middleware::Next,
189) -> axum::response::Response {
190    let ok = match fetch_site_is_same_origin(req.headers()) {
191        Some(same_origin) => same_origin,
192        None => {
193            let headers = req.headers().clone();
194            let trusted = trusted_source(&headers).await;
195            origin_matches(trusted.as_ref(), extract_origin(&headers).as_deref())
196        }
197    };
198    if !ok {
199        return forbidden();
200    }
201    next.run(req).await
202}
203
204#[cfg(all(test, feature = "server"))]
205mod tests {
206    use super::*;
207    use axum::http::{HeaderMap, HeaderValue, Method};
208
209    #[test]
210    fn is_write_method_recognizes_writes() {
211        assert!(is_write_method(&Method::POST));
212        assert!(is_write_method(&Method::PUT));
213        assert!(is_write_method(&Method::PATCH));
214        assert!(is_write_method(&Method::DELETE));
215        assert!(!is_write_method(&Method::GET));
216        assert!(!is_write_method(&Method::OPTIONS));
217        assert!(!is_write_method(&Method::HEAD));
218    }
219
220    #[test]
221    fn normalize_strips_path_and_query() {
222        assert_eq!(
223            normalize_origin("https://example.com/a/b?c=1"),
224            "https://example.com"
225        );
226    }
227
228    #[test]
229    fn normalize_preserves_nondefault_port() {
230        assert_eq!(
231            normalize_origin("http://localhost:3000/x"),
232            "http://localhost:3000"
233        );
234    }
235
236    #[test]
237    fn normalize_drops_default_ports() {
238        assert_eq!(
239            normalize_origin("https://example.com:443/path"),
240            "https://example.com"
241        );
242        assert_eq!(
243            normalize_origin("http://example.com:80/path"),
244            "http://example.com"
245        );
246    }
247
248    #[test]
249    fn normalize_keeps_explicit_nondefault_https_port() {
250        assert_eq!(
251            normalize_origin("https://example.com:8443"),
252            "https://example.com:8443"
253        );
254    }
255
256    #[test]
257    fn normalize_plain_origin_no_path() {
258        assert_eq!(
259            normalize_origin("https://example.com"),
260            "https://example.com"
261        );
262    }
263
264    #[test]
265    fn extract_origin_prefers_origin_header() {
266        let mut headers = HeaderMap::new();
267        headers.insert(
268            axum::http::header::ORIGIN,
269            HeaderValue::from_static("https://example.com"),
270        );
271        assert_eq!(
272            extract_origin(&headers),
273            Some("https://example.com".to_string())
274        );
275    }
276
277    #[test]
278    fn extract_origin_falls_back_to_referer() {
279        let mut headers = HeaderMap::new();
280        headers.insert(
281            axum::http::header::REFERER,
282            HeaderValue::from_static("https://example.com/posts/1"),
283        );
284        // Referer 的路径被剥离,只保留 scheme://host。
285        assert_eq!(
286            extract_origin(&headers),
287            Some("https://example.com".to_string())
288        );
289    }
290
291    #[test]
292    fn extract_origin_returns_none_when_both_absent() {
293        let headers = HeaderMap::new();
294        assert_eq!(extract_origin(&headers), None);
295    }
296
297    #[test]
298    fn origin_matches_requires_both_origins() {
299        let site = TrustedSource::Origin("https://example.com".to_string());
300        assert!(origin_matches(Some(&site), Some("https://example.com")));
301        assert!(!origin_matches(Some(&site), None));
302        assert!(!origin_matches(None, Some("https://example.com")));
303        assert!(!origin_matches(Some(&site), Some("https://evil.example")));
304        // 配置了站点地址时协议也要一致。
305        assert!(!origin_matches(Some(&site), Some("http://example.com")));
306    }
307
308    #[test]
309    fn origin_matches_host_fallback_ignores_scheme_only() {
310        let host = TrustedSource::Host(normalize_authority("localhost:8080").to_string());
311        assert!(origin_matches(Some(&host), Some("http://localhost:8080")));
312        assert!(origin_matches(Some(&host), Some("https://localhost:8080")));
313        assert!(!origin_matches(Some(&host), Some("http://localhost:3000")));
314        assert!(!origin_matches(Some(&host), Some("http://127.0.0.1:8080")));
315        assert!(!origin_matches(
316            Some(&host),
317            Some("http://evil.localhost:8080")
318        ));
319        // 沙箱 iframe、file:// 等来源为 "null"。
320        assert!(!origin_matches(Some(&host), Some("null")));
321        assert!(!origin_matches(Some(&host), None));
322
323        // 默认端口两侧都省略:Host 头 `example.com:443` 与 Origin `https://example.com` 一致。
324        let host = TrustedSource::Host(normalize_authority("example.com:443").to_string());
325        let incoming = normalize_origin("https://example.com:443");
326        assert!(origin_matches(Some(&host), Some(&incoming)));
327        assert!(origin_matches(Some(&host), Some("https://example.com")));
328    }
329
330    #[test]
331    fn fetch_site_trusts_only_same_origin() {
332        let with = |value: &'static str| {
333            let mut headers = HeaderMap::new();
334            headers.insert("sec-fetch-site", HeaderValue::from_static(value));
335            fetch_site_is_same_origin(&headers)
336        };
337        assert_eq!(with("same-origin"), Some(true));
338        // same-site 含兄弟子域;none 为地址栏直开,本站 UI 不会产生。
339        assert_eq!(with("same-site"), Some(false));
340        assert_eq!(with("cross-site"), Some(false));
341        assert_eq!(with("none"), Some(false));
342    }
343
344    // ── APP_BASE_URL 启动告警 ──────────────────────────────────────
345    // 这些测试读/写 APP_BASE_URL 全局环境变量,用 serial 串行隔离,
346    // 与 rate_limit.rs 的 env 测试同模式(保存 → 设值 → 恢复)。
347
348    #[test]
349    #[serial_test::serial]
350    fn app_base_url_is_set_false_when_unset() {
351        let original = std::env::var("APP_BASE_URL").ok();
352        std::env::remove_var("APP_BASE_URL");
353        assert!(!app_base_url_is_set());
354        restore_env("APP_BASE_URL", original);
355    }
356
357    #[test]
358    #[serial_test::serial]
359    fn app_base_url_is_set_false_when_empty() {
360        let original = std::env::var("APP_BASE_URL").ok();
361        std::env::set_var("APP_BASE_URL", "");
362        assert!(!app_base_url_is_set());
363        restore_env("APP_BASE_URL", original);
364    }
365
366    #[test]
367    #[serial_test::serial]
368    fn app_base_url_is_set_false_when_whitespace_only() {
369        let original = std::env::var("APP_BASE_URL").ok();
370        std::env::set_var("APP_BASE_URL", "   \t  ");
371        assert!(!app_base_url_is_set());
372        restore_env("APP_BASE_URL", original);
373    }
374
375    #[test]
376    #[serial_test::serial]
377    fn app_base_url_is_set_true_when_set() {
378        let original = std::env::var("APP_BASE_URL").ok();
379        std::env::set_var("APP_BASE_URL", "https://example.com");
380        assert!(app_base_url_is_set());
381        restore_env("APP_BASE_URL", original);
382    }
383
384    #[test]
385    #[serial_test::serial]
386    fn app_base_url_is_set_trims_surrounding_whitespace() {
387        let original = std::env::var("APP_BASE_URL").ok();
388        std::env::set_var("APP_BASE_URL", "  https://example.com  ");
389        assert!(app_base_url_is_set());
390        restore_env("APP_BASE_URL", original);
391    }
392
393    /// 恢复环境变量到测试前的状态,避免污染其他测试。
394    fn restore_env(key: &str, original: Option<String>) {
395        match original {
396            Some(value) => std::env::set_var(key, value),
397            None => std::env::remove_var(key),
398        }
399    }
400}