1use crate::shared::csrf::request_origin;
3pub use crate::shared::csrf::{
4 CSRF_COOKIE, CSRF_HEADER, CsrfError, CsrfErrorKind, RequestSourceMode, Result,
5 build_csrf_token, ensure_headers_allowed,
6};
7use actix_web::{HttpRequest, dev::ServiceRequest, http::header};
8use std::sync::OnceLock;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CsrfTokenNames {
13 shared: crate::shared::csrf::CsrfTokenNames,
14 header_name: header::HeaderName,
15}
16
17impl CsrfTokenNames {
18 pub fn new(cookie_name: impl Into<String>, header_name: impl AsRef<str>) -> Result<Self> {
23 let shared = crate::shared::csrf::CsrfTokenNames::new(cookie_name, header_name)?;
24 let header_name = header::HeaderName::from_bytes(shared.header_name_str().as_bytes())
25 .map_err(|error| CsrfError::new(CsrfErrorKind::TokenNameInvalid, error.to_string()))?;
26 Ok(Self {
27 shared,
28 header_name,
29 })
30 }
31 #[must_use]
33 pub fn cookie_name(&self) -> &str {
34 self.shared.cookie_name()
35 }
36 #[must_use]
38 pub fn header_name(&self) -> &header::HeaderName {
39 &self.header_name
40 }
41 #[must_use]
43 pub fn header_name_str(&self) -> &str {
44 self.shared.header_name_str()
45 }
46}
47
48impl Default for CsrfTokenNames {
49 fn default() -> Self {
50 Self {
51 shared: crate::shared::csrf::CsrfTokenNames::default(),
52 header_name: header::HeaderName::from_static("x-csrf-token"),
53 }
54 }
55}
56
57#[must_use]
59pub fn default_csrf_token_names() -> &'static CsrfTokenNames {
60 static NAMES: OnceLock<CsrfTokenNames> = OnceLock::new();
61 NAMES.get_or_init(CsrfTokenNames::default)
62}
63pub fn ensure_double_submit_token(req: &HttpRequest) -> Result<()> {
72 ensure_double_submit_token_with_names(req, default_csrf_token_names())
73}
74
75pub fn ensure_double_submit_token_with_names(
85 req: &HttpRequest,
86 names: &CsrfTokenNames,
87) -> Result<()> {
88 let cookie = req.cookie(names.cookie_name());
89 crate::shared::csrf::ensure_token_values(
90 cookie.as_ref().map(actix_web::cookie::Cookie::value),
91 req.headers()
92 .get(names.header_name_str())
93 .and_then(|value| value.to_str().ok()),
94 )
95}
96
97pub fn ensure_service_double_submit_token(req: &ServiceRequest) -> Result<()> {
106 ensure_double_submit_token(req.request())
107}
108
109pub fn ensure_service_double_submit_token_with_names(
116 req: &ServiceRequest,
117 names: &CsrfTokenNames,
118) -> Result<()> {
119 ensure_double_submit_token_with_names(req.request(), names)
120}
121
122pub fn ensure_request_source_allowed(
128 req: &HttpRequest,
129 public_site_origins: &[String],
130 mode: RequestSourceMode,
131) -> Result<()> {
132 let conn = req.connection_info();
133 let request_origin = request_origin(conn.scheme(), conn.host())?;
134 ensure_headers_allowed(
135 header_value(req, header::ORIGIN),
136 header_value(req, header::REFERER),
137 header_value(req, header::HeaderName::from_static("sec-fetch-site")),
138 &request_origin,
139 public_site_origins,
140 mode,
141 )
142}
143
144pub fn ensure_service_request_source_allowed(
150 req: &ServiceRequest,
151 public_site_origins: &[String],
152 mode: RequestSourceMode,
153) -> Result<()> {
154 let conn = req.connection_info();
155 let request_origin = request_origin(conn.scheme(), conn.host())?;
156 ensure_headers_allowed(
157 header_value(req.request(), header::ORIGIN),
158 header_value(req.request(), header::REFERER),
159 header_value(
160 req.request(),
161 header::HeaderName::from_static("sec-fetch-site"),
162 ),
163 &request_origin,
164 public_site_origins,
165 mode,
166 )
167}
168
169#[must_use]
171pub fn is_unsafe_method(method: &actix_web::http::Method) -> bool {
172 !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "TRACE")
173}
174fn header_value(req: &HttpRequest, name: header::HeaderName) -> Option<&str> {
175 req.headers()
176 .get(name)
177 .map(|value| value.to_str().unwrap_or("\u{fffd}"))
178}
179
180#[cfg(test)]
181mod tests {
182 use actix_web::cookie::Cookie;
183
184 use super::{
185 CSRF_COOKIE, CSRF_HEADER, CsrfErrorKind, CsrfTokenNames, RequestSourceMode,
186 ensure_double_submit_token, ensure_double_submit_token_with_names, ensure_headers_allowed,
187 ensure_request_source_allowed,
188 };
189
190 fn host_with_len(len: usize) -> String {
191 let suffix = ".example.com";
192 format!("{}{}", "a".repeat(len - suffix.len()), suffix)
193 }
194
195 #[test]
196 fn configured_header_name_remains_usable_with_actix_header_apis() {
197 let names = CsrfTokenNames::new("custom_csrf", "X-Custom-CSRF").unwrap();
198 let req = actix_web::test::TestRequest::post()
199 .cookie(Cookie::new("custom_csrf", "token"))
200 .insert_header((names.header_name(), "token"))
201 .to_http_request();
202 assert!(ensure_double_submit_token_with_names(&req, &names).is_ok());
203 }
204
205 #[test]
206 fn opaque_source_headers_are_rejected_in_optional_mode() {
207 for (name, expected) in [
208 ("origin", CsrfErrorKind::RequestOriginInvalid),
209 ("referer", CsrfErrorKind::RequestSchemeInvalid),
210 ("sec-fetch-site", CsrfErrorKind::RequestHeaderValueInvalid),
211 ] {
212 let req = actix_web::test::TestRequest::post()
213 .insert_header((
214 name,
215 actix_web::http::header::HeaderValue::from_bytes(&[0xff]).unwrap(),
216 ))
217 .to_http_request();
218 assert_eq!(
219 ensure_request_source_allowed(&req, &[], RequestSourceMode::OptionalWhenPresent)
220 .unwrap_err()
221 .kind(),
222 expected
223 );
224 }
225 }
226
227 #[test]
228 fn rejects_oversized_request_source_values_before_normalization() {
229 let max_host = host_with_len(512);
230 let req = actix_web::test::TestRequest::post()
231 .insert_header(("Host", max_host.as_str()))
232 .insert_header(("Origin", format!("http://{max_host}")))
233 .to_http_request();
234 assert!(ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).is_ok());
235
236 let long_host = host_with_len(513);
237 let req = actix_web::test::TestRequest::post()
238 .insert_header(("Host", long_host))
239 .insert_header(("Origin", "https://forge.example.com"))
240 .to_http_request();
241 let err =
242 ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).unwrap_err();
243 assert_eq!(err.kind(), CsrfErrorKind::RequestHostInvalid);
244
245 let req = actix_web::test::TestRequest::post()
246 .insert_header(("Host", "forge.example.com"))
247 .insert_header(("X-Forwarded-Proto", "x".repeat(17)))
248 .insert_header(("Origin", "https://forge.example.com"))
249 .to_http_request();
250 let err =
251 ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).unwrap_err();
252 assert_eq!(err.kind(), CsrfErrorKind::RequestSchemeInvalid);
253
254 let max_origin = format!("https://{}", host_with_len(2040));
255 assert_eq!(max_origin.len(), 2048);
256 assert!(
257 ensure_headers_allowed(
258 Some(&max_origin),
259 None,
260 None,
261 "https://forge.example.com",
262 std::slice::from_ref(&max_origin),
263 RequestSourceMode::OptionalWhenPresent,
264 )
265 .is_ok()
266 );
267
268 let long_origin = format!("https://{}", host_with_len(2041));
269 assert_eq!(long_origin.len(), 2049);
270 let err = ensure_headers_allowed(
271 Some(&long_origin),
272 None,
273 None,
274 "https://forge.example.com",
275 &[],
276 RequestSourceMode::OptionalWhenPresent,
277 )
278 .unwrap_err();
279 assert_eq!(err.kind(), CsrfErrorKind::RequestOriginInvalid);
280
281 let max_referer_authority = host_with_len(528);
282 let max_referer_origin = format!("https://{max_referer_authority}");
283 let max_referer = format!("{max_referer_origin}/settings");
284 assert!(
285 ensure_headers_allowed(
286 None,
287 Some(&max_referer),
288 None,
289 "https://forge.example.com",
290 &[max_referer_origin],
291 RequestSourceMode::OptionalWhenPresent,
292 )
293 .is_ok()
294 );
295
296 let long_referer_authority = format!("https://{}.example.com/settings", "a".repeat(600));
297 let err = ensure_headers_allowed(
298 None,
299 Some(&long_referer_authority),
300 None,
301 "https://forge.example.com",
302 &[],
303 RequestSourceMode::OptionalWhenPresent,
304 )
305 .unwrap_err();
306 assert_eq!(err.kind(), CsrfErrorKind::RequestRefererInvalid);
307
308 let max_fetch_site = "x".repeat(64);
309 assert!(
310 ensure_headers_allowed(
311 None,
312 None,
313 Some(&max_fetch_site),
314 "https://forge.example.com",
315 &[],
316 RequestSourceMode::OptionalWhenPresent,
317 )
318 .is_ok()
319 );
320
321 let long_fetch_site = "x".repeat(65);
322 let err = ensure_headers_allowed(
323 None,
324 None,
325 Some(&long_fetch_site),
326 "https://forge.example.com",
327 &[],
328 RequestSourceMode::OptionalWhenPresent,
329 )
330 .unwrap_err();
331 assert_eq!(err.kind(), CsrfErrorKind::RequestHeaderValueInvalid);
332 }
333
334 #[test]
335 fn accepts_ipv6_request_host_origin_match() {
336 let req = actix_web::test::TestRequest::post()
337 .insert_header(("Host", "[2001:db8::1]:8443"))
338 .insert_header(("Origin", "http://[2001:db8::1]:8443"))
339 .to_http_request();
340
341 assert!(ensure_request_source_allowed(&req, &[], RequestSourceMode::Required).is_ok());
342 }
343
344 #[test]
345 fn csrf_token_check_requires_cookie_for_cookie_authenticated_writes() {
346 let req = actix_web::test::TestRequest::post()
347 .uri("/api/v1/auth/profile")
348 .to_http_request();
349
350 let err = ensure_double_submit_token(&req).unwrap_err();
351 assert_eq!(err.kind(), CsrfErrorKind::CookieMissing);
352 }
353
354 #[test]
355 fn csrf_token_check_requires_matching_cookie_and_header() {
356 let req = actix_web::test::TestRequest::patch()
357 .uri("/api/v1/auth/profile")
358 .insert_header(("Origin", "http://localhost"))
359 .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
360 .insert_header((CSRF_HEADER, "token-a"))
361 .to_http_request();
362 assert!(ensure_double_submit_token(&req).is_ok());
363
364 let missing_header = actix_web::test::TestRequest::patch()
365 .uri("/api/v1/auth/profile")
366 .insert_header(("Origin", "http://localhost"))
367 .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
368 .to_http_request();
369 let err = ensure_double_submit_token(&missing_header).unwrap_err();
370 assert_eq!(err.kind(), CsrfErrorKind::HeaderMissing);
371
372 let mismatch = actix_web::test::TestRequest::patch()
373 .uri("/api/v1/auth/profile")
374 .insert_header(("Origin", "http://localhost"))
375 .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
376 .insert_header((CSRF_HEADER, "token-b"))
377 .to_http_request();
378 let err = ensure_double_submit_token(&mismatch).unwrap_err();
379 assert_eq!(err.kind(), CsrfErrorKind::TokenInvalid);
380 }
381
382 #[test]
383 fn csrf_token_check_rejects_tokens_of_different_lengths() {
384 let req = actix_web::test::TestRequest::patch()
385 .uri("/api/v1/auth/profile")
386 .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
387 .insert_header((CSRF_HEADER, "token-a-with-a-longer-value"))
388 .to_http_request();
389 let err = ensure_double_submit_token(&req).unwrap_err();
390 assert_eq!(err.kind(), CsrfErrorKind::TokenInvalid);
391 }
392
393 #[test]
394 fn csrf_token_check_accepts_custom_cookie_and_header_names() {
395 let names = CsrfTokenNames::new("aster_yggdrasil_csrf", "X-Yggdrasil-CSRF-Token")
396 .expect("custom CSRF token names should be valid");
397 assert_eq!(names.cookie_name(), "aster_yggdrasil_csrf");
398 assert_eq!(names.header_name_str(), "x-yggdrasil-csrf-token");
399
400 let req = actix_web::test::TestRequest::patch()
401 .cookie(Cookie::new("aster_yggdrasil_csrf", "token-a"))
402 .insert_header(("X-Yggdrasil-CSRF-Token", "token-a"))
403 .to_http_request();
404 assert!(ensure_double_submit_token_with_names(&req, &names).is_ok());
405
406 let default_req = actix_web::test::TestRequest::patch()
407 .cookie(Cookie::new(CSRF_COOKIE, "token-a"))
408 .insert_header((CSRF_HEADER, "token-a"))
409 .to_http_request();
410 let err = ensure_double_submit_token_with_names(&default_req, &names).unwrap_err();
411 assert_eq!(err.kind(), CsrfErrorKind::CookieMissing);
412 }
413}