1#[cfg(feature = "server")]
12use axum::http::{HeaderMap, Method, Request};
13
14#[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#[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#[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#[cfg(feature = "server")]
58fn normalize_origin(input: &str) -> String {
59 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#[cfg(feature = "server")]
69fn normalize_authority(input: &str) -> &str {
70 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#[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#[cfg(feature = "server")]
97enum TrustedSource {
98 Origin(String),
100 Host(String),
104}
105
106#[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#[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#[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#[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#[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 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 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 assert!(!origin_matches(Some(&host), Some("null")));
321 assert!(!origin_matches(Some(&host), None));
322
323 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 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 #[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 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}