1#[cfg(feature = "server")]
11use axum::http::{HeaderMap, Method, Request};
12
13#[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#[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#[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 let authority = match rest.split_once('/') {
66 Some((auth, _)) => auth,
67 None => rest,
68 };
69 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#[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#[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#[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#[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 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 #[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 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}