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")]
118pub async fn csrf_middleware(
119 req: Request<axum::body::Body>,
120 next: axum::middleware::Next,
121) -> axum::response::Response {
122 if is_write_method(req.method()) {
123 let headers = req.headers().clone();
124 let trusted = trusted_origin(&headers).await;
125 let incoming = extract_origin(&headers);
126 let ok = match (&trusted, &incoming) {
127 (Some(t), Some(o)) => t == o,
128 _ => true,
130 };
131 if !ok {
132 return axum::response::Response::builder()
133 .status(axum::http::StatusCode::FORBIDDEN)
134 .body(axum::body::Body::empty())
135 .expect("static forbidden response is always valid");
136 }
137 }
138 next.run(req).await
139}
140
141#[cfg(all(test, feature = "server"))]
142mod tests {
143 use super::*;
144 use axum::http::{HeaderMap, HeaderValue, Method};
145
146 #[test]
147 fn is_write_method_recognizes_writes() {
148 assert!(is_write_method(&Method::POST));
149 assert!(is_write_method(&Method::PUT));
150 assert!(is_write_method(&Method::PATCH));
151 assert!(is_write_method(&Method::DELETE));
152 assert!(!is_write_method(&Method::GET));
153 assert!(!is_write_method(&Method::OPTIONS));
154 assert!(!is_write_method(&Method::HEAD));
155 }
156
157 #[test]
158 fn normalize_strips_path_and_query() {
159 assert_eq!(
160 normalize_origin("https://example.com/a/b?c=1"),
161 "https://example.com"
162 );
163 }
164
165 #[test]
166 fn normalize_preserves_nondefault_port() {
167 assert_eq!(
168 normalize_origin("http://localhost:3000/x"),
169 "http://localhost:3000"
170 );
171 }
172
173 #[test]
174 fn normalize_drops_default_ports() {
175 assert_eq!(
176 normalize_origin("https://example.com:443/path"),
177 "https://example.com"
178 );
179 assert_eq!(
180 normalize_origin("http://example.com:80/path"),
181 "http://example.com"
182 );
183 }
184
185 #[test]
186 fn normalize_keeps_explicit_nondefault_https_port() {
187 assert_eq!(
188 normalize_origin("https://example.com:8443"),
189 "https://example.com:8443"
190 );
191 }
192
193 #[test]
194 fn normalize_plain_origin_no_path() {
195 assert_eq!(
196 normalize_origin("https://example.com"),
197 "https://example.com"
198 );
199 }
200
201 #[test]
202 fn extract_origin_prefers_origin_header() {
203 let mut headers = HeaderMap::new();
204 headers.insert(
205 axum::http::header::ORIGIN,
206 HeaderValue::from_static("https://example.com"),
207 );
208 assert_eq!(
209 extract_origin(&headers),
210 Some("https://example.com".to_string())
211 );
212 }
213
214 #[test]
215 fn extract_origin_falls_back_to_referer() {
216 let mut headers = HeaderMap::new();
217 headers.insert(
218 axum::http::header::REFERER,
219 HeaderValue::from_static("https://example.com/posts/1"),
220 );
221 assert_eq!(
223 extract_origin(&headers),
224 Some("https://example.com".to_string())
225 );
226 }
227
228 #[test]
229 fn extract_origin_returns_none_when_both_absent() {
230 let headers = HeaderMap::new();
231 assert_eq!(extract_origin(&headers), None);
232 }
233
234 #[test]
239 #[serial_test::serial]
240 fn app_base_url_is_set_false_when_unset() {
241 let original = std::env::var("APP_BASE_URL").ok();
242 std::env::remove_var("APP_BASE_URL");
243 assert!(!app_base_url_is_set());
244 restore_env("APP_BASE_URL", original);
245 }
246
247 #[test]
248 #[serial_test::serial]
249 fn app_base_url_is_set_false_when_empty() {
250 let original = std::env::var("APP_BASE_URL").ok();
251 std::env::set_var("APP_BASE_URL", "");
252 assert!(!app_base_url_is_set());
253 restore_env("APP_BASE_URL", original);
254 }
255
256 #[test]
257 #[serial_test::serial]
258 fn app_base_url_is_set_false_when_whitespace_only() {
259 let original = std::env::var("APP_BASE_URL").ok();
260 std::env::set_var("APP_BASE_URL", " \t ");
261 assert!(!app_base_url_is_set());
262 restore_env("APP_BASE_URL", original);
263 }
264
265 #[test]
266 #[serial_test::serial]
267 fn app_base_url_is_set_true_when_set() {
268 let original = std::env::var("APP_BASE_URL").ok();
269 std::env::set_var("APP_BASE_URL", "https://example.com");
270 assert!(app_base_url_is_set());
271 restore_env("APP_BASE_URL", original);
272 }
273
274 #[test]
275 #[serial_test::serial]
276 fn app_base_url_is_set_trims_surrounding_whitespace() {
277 let original = std::env::var("APP_BASE_URL").ok();
278 std::env::set_var("APP_BASE_URL", " https://example.com ");
279 assert!(app_base_url_is_set());
280 restore_env("APP_BASE_URL", original);
281 }
282
283 fn restore_env(key: &str, original: Option<String>) {
285 match original {
286 Some(value) => std::env::set_var(key, value),
287 None => std::env::remove_var(key),
288 }
289 }
290}