1use http::{
3 Method,
4 header::{HeaderName, InvalidHeaderName},
5};
6use rand::RngExt;
7use std::sync::OnceLock;
8use subtle::ConstantTimeEq;
9
10pub const CSRF_COOKIE: &str = "aster_csrf";
14pub const CSRF_HEADER: &str = "X-CSRF-Token";
18const DEFAULT_CSRF_HEADER_LOWER: &str = "x-csrf-token";
19
20const MAX_REQUEST_SCHEME_LEN: usize = 16;
21const MAX_REQUEST_HOST_LEN: usize = 512;
22const MAX_REFERER_AUTHORITY_LEN: usize = MAX_REQUEST_HOST_LEN + 16;
23const MAX_SOURCE_HEADER_LEN: usize = 2048;
24const MAX_SEC_FETCH_SITE_LEN: usize = 64;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum RequestSourceMode {
29 OptionalWhenPresent,
31 Required,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum CsrfErrorKind {
38 TokenNameInvalid,
43 CookieMissing,
45 HeaderMissing,
47 TokenInvalid,
49 RequestSourceUntrusted,
51 RequestOriginUntrusted,
53 RequestRefererUntrusted,
55 RequestSourceMissing,
57 RequestSchemeInvalid,
59 RequestHostInvalid,
61 RequestOriginInvalid,
63 RequestRefererInvalid,
65 RequestHeaderValueInvalid,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
71#[error("{message}")]
72pub struct CsrfError {
73 kind: CsrfErrorKind,
74 message: String,
75}
76
77impl CsrfError {
78 pub(crate) fn new(kind: CsrfErrorKind, message: impl Into<String>) -> Self {
79 Self {
80 kind,
81 message: message.into(),
82 }
83 }
84
85 #[must_use]
87 pub fn kind(&self) -> CsrfErrorKind {
88 self.kind
89 }
90
91 #[must_use]
93 pub fn message(&self) -> &str {
94 &self.message
95 }
96}
97
98pub type Result<T> = std::result::Result<T, CsrfError>;
100
101#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct CsrfTokenNames {
110 cookie_name: String,
111 header_name: HeaderName,
112}
113
114impl CsrfTokenNames {
115 pub fn new(cookie_name: impl Into<String>, header_name: impl AsRef<str>) -> Result<Self> {
125 let cookie_name = cookie_name.into();
126 validate_cookie_name(&cookie_name)?;
127 let header_name = parse_header_name(header_name.as_ref())?;
128 Ok(Self {
129 cookie_name,
130 header_name,
131 })
132 }
133
134 pub fn cookie_name(&self) -> &str {
136 &self.cookie_name
137 }
138
139 pub fn header_name(&self) -> &HeaderName {
141 &self.header_name
142 }
143
144 pub fn header_name_str(&self) -> &str {
149 self.header_name.as_str()
150 }
151}
152
153impl Default for CsrfTokenNames {
154 fn default() -> Self {
155 Self {
156 cookie_name: CSRF_COOKIE.to_string(),
157 header_name: HeaderName::from_static(DEFAULT_CSRF_HEADER_LOWER),
158 }
159 }
160}
161
162pub fn default_csrf_token_names() -> &'static CsrfTokenNames {
167 static DEFAULT_NAMES: OnceLock<CsrfTokenNames> = OnceLock::new();
168 DEFAULT_NAMES.get_or_init(CsrfTokenNames::default)
169}
170
171#[must_use]
173pub fn is_unsafe_method(method: &Method) -> bool {
174 !matches!(
175 *method,
176 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
177 )
178}
179
180#[must_use]
182pub fn build_csrf_token() -> String {
183 use base64::Engine;
184
185 let mut bytes = [0_u8; 32];
186 rand::rng().fill(&mut bytes);
187 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
188}
189
190pub fn ensure_headers_allowed(
196 origin: Option<&str>,
197 referer: Option<&str>,
198 sec_fetch_site: Option<&str>,
199 request_origin: &str,
200 public_site_origins: &[String],
201 mode: RequestSourceMode,
202) -> Result<()> {
203 let fetch_site = source_header_value(
204 sec_fetch_site,
205 MAX_SEC_FETCH_SITE_LEN,
206 "Sec-Fetch-Site",
207 CsrfErrorKind::RequestHeaderValueInvalid,
208 )?
209 .map(str::to_ascii_lowercase);
210
211 if fetch_site.as_ref().is_some_and(|value| !value.is_ascii()) {
212 return Err(CsrfError::new(
213 CsrfErrorKind::RequestHeaderValueInvalid,
214 "invalid Sec-Fetch-Site header",
215 ));
216 }
217
218 if let Some("cross-site" | "none") = fetch_site.as_deref() {
219 return Err(CsrfError::new(
220 CsrfErrorKind::RequestSourceUntrusted,
221 "untrusted request source for cookie-authenticated action",
222 ));
223 }
224 let same_site_fetch = fetch_site.as_deref() == Some("same-site");
225
226 if let Some(origin) = source_header_value(
227 origin,
228 MAX_SOURCE_HEADER_LEN,
229 "Origin",
230 CsrfErrorKind::RequestOriginInvalid,
231 )?
232 .map(|value| normalize_origin(value, CsrfErrorKind::RequestOriginInvalid))
233 .transpose()?
234 {
235 if origin_is_trusted(&origin, request_origin, public_site_origins) {
236 return Ok(());
237 }
238 return Err(CsrfError::new(
239 CsrfErrorKind::RequestOriginUntrusted,
240 "untrusted request origin for cookie-authenticated action",
241 ));
242 }
243
244 if let Some(referer) = trimmed_header_value(referer) {
245 let referer_origin = origin_from_url(referer)?;
246 if origin_is_trusted(&referer_origin, request_origin, public_site_origins) {
247 return Ok(());
248 }
249 return Err(CsrfError::new(
250 CsrfErrorKind::RequestRefererUntrusted,
251 "untrusted request referer for cookie-authenticated action",
252 ));
253 }
254
255 if same_site_fetch {
256 return Err(CsrfError::new(
257 CsrfErrorKind::RequestSourceUntrusted,
258 "missing trusted request source for same-site cookie-authenticated action",
259 ));
260 }
261
262 match mode {
263 RequestSourceMode::OptionalWhenPresent => Ok(()),
264 RequestSourceMode::Required => Err(CsrfError::new(
265 CsrfErrorKind::RequestSourceMissing,
266 "missing request source for cookie-authenticated action",
267 )),
268 }
269}
270
271fn validate_cookie_name(cookie_name: &str) -> Result<()> {
272 if cookie_name.is_empty() {
273 return Err(CsrfError::new(
274 CsrfErrorKind::TokenNameInvalid,
275 "CSRF cookie name cannot be empty",
276 ));
277 }
278 if cookie_name
279 .bytes()
280 .any(|byte| byte <= 0x20 || byte >= 0x7f || b"()<>@,;:\\\"/[]?={}".contains(&byte))
281 {
282 return Err(CsrfError::new(
283 CsrfErrorKind::TokenNameInvalid,
284 "CSRF cookie name contains invalid characters",
285 ));
286 }
287 Ok(())
288}
289
290fn parse_header_name(header_name: &str) -> Result<HeaderName> {
291 HeaderName::from_bytes(header_name.as_bytes()).map_err(|error| header_name_error(&error))
292}
293
294fn header_name_error(error: &InvalidHeaderName) -> CsrfError {
295 CsrfError::new(
296 CsrfErrorKind::TokenNameInvalid,
297 format!("invalid CSRF header name: {error}"),
298 )
299}
300
301pub fn request_origin(scheme: &str, host: &str) -> Result<String> {
306 ensure_value_len(
307 scheme,
308 MAX_REQUEST_SCHEME_LEN,
309 "request scheme",
310 CsrfErrorKind::RequestSchemeInvalid,
311 )?;
312 ensure_value_len(
313 host,
314 MAX_REQUEST_HOST_LEN,
315 "request host",
316 CsrfErrorKind::RequestHostInvalid,
317 )?;
318 normalize_origin(
319 &format!("{scheme}://{host}"),
320 CsrfErrorKind::RequestHostInvalid,
321 )
322 .map_err(|_| CsrfError::new(CsrfErrorKind::RequestHostInvalid, "invalid request host"))
323}
324
325fn normalize_origin(origin: &str, kind: CsrfErrorKind) -> Result<String> {
326 aster_forge_utils::url::normalize_origin(origin, false)
327 .map_err(|_| CsrfError::new(kind, "invalid origin"))
328}
329
330fn origin_is_trusted(origin: &str, request_origin: &str, public_site_origins: &[String]) -> bool {
331 origin == request_origin || public_site_origins.iter().any(|allowed| allowed == origin)
332}
333
334fn source_header_value<'a>(
335 value: Option<&'a str>,
336 max_len: usize,
337 label: &str,
338 kind: CsrfErrorKind,
339) -> Result<Option<&'a str>> {
340 let Some(value) = trimmed_header_value(value) else {
341 return Ok(None);
342 };
343 ensure_value_len(value, max_len, label, kind)?;
344 Ok(Some(value))
345}
346
347fn trimmed_header_value(value: Option<&str>) -> Option<&str> {
348 value.map(str::trim).filter(|value| !value.is_empty())
349}
350
351fn ensure_value_len(value: &str, max_len: usize, label: &str, kind: CsrfErrorKind) -> Result<()> {
352 if value.len() > max_len {
353 return Err(CsrfError::new(
354 kind,
355 format!("{label} exceeds {max_len} bytes"),
356 ));
357 }
358 Ok(())
359}
360
361fn origin_from_url(url: &str) -> Result<String> {
362 let scheme_end = url.find("://").ok_or_else(|| {
363 CsrfError::new(
364 CsrfErrorKind::RequestSchemeInvalid,
365 "invalid Referer header",
366 )
367 })?;
368 let scheme = &url[..scheme_end];
369 ensure_value_len(
370 scheme,
371 MAX_REQUEST_SCHEME_LEN,
372 "Referer scheme",
373 CsrfErrorKind::RequestSchemeInvalid,
374 )?;
375
376 let authority_start = scheme_end + 3;
377 let authority_tail = &url[authority_start..];
378 let authority_end = authority_tail
379 .char_indices()
380 .find_map(|(idx, ch)| matches!(ch, '/' | '?' | '#').then_some(authority_start + idx))
381 .unwrap_or(url.len());
382 let authority = &url[authority_start..authority_end];
383 ensure_value_len(
384 authority,
385 MAX_REFERER_AUTHORITY_LEN,
386 "Referer authority",
387 CsrfErrorKind::RequestRefererInvalid,
388 )?;
389
390 normalize_origin(
391 &format!("{}://{}", scheme.to_ascii_lowercase(), authority),
392 CsrfErrorKind::RequestRefererInvalid,
393 )
394 .map_err(|_| {
395 CsrfError::new(
396 CsrfErrorKind::RequestRefererInvalid,
397 "invalid Referer header",
398 )
399 })
400}
401
402pub fn ensure_token_values(cookie: Option<&str>, header: Option<&str>) -> Result<()> {
407 let cookie = cookie
408 .ok_or_else(|| CsrfError::new(CsrfErrorKind::CookieMissing, "missing CSRF cookie"))?;
409 let header = header
410 .map(str::trim)
411 .filter(|value| !value.is_empty())
412 .ok_or_else(|| CsrfError::new(CsrfErrorKind::HeaderMissing, "missing CSRF header"))?;
413 if header.len() != cookie.len() || !bool::from(header.as_bytes().ct_eq(cookie.as_bytes())) {
415 return Err(CsrfError::new(
416 CsrfErrorKind::TokenInvalid,
417 "invalid CSRF token",
418 ));
419 }
420 Ok(())
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 #[test]
427 fn token_comparison_checks_every_byte_and_public_length() {
428 let token = build_csrf_token();
429 assert!(ensure_token_values(Some(&token), Some(&token)).is_ok());
430 for index in 0..token.len() {
431 let mut changed = token.as_bytes().to_vec();
432 changed[index] = if changed[index] == b'a' { b'b' } else { b'a' };
433 let changed = String::from_utf8(changed).unwrap();
434 assert_eq!(
435 ensure_token_values(Some(&token), Some(&changed))
436 .unwrap_err()
437 .kind(),
438 CsrfErrorKind::TokenInvalid
439 );
440 }
441 assert_eq!(
442 ensure_token_values(Some(&token), Some(&token[..token.len() - 1]))
443 .unwrap_err()
444 .kind(),
445 CsrfErrorKind::TokenInvalid
446 );
447 }
448 #[test]
449 fn accepts_same_origin_and_public_site_origin() {
450 assert!(
451 ensure_headers_allowed(
452 Some("http://localhost"),
453 None,
454 Some("same-origin"),
455 "http://localhost",
456 &["https://forge.example.com".to_string()],
457 RequestSourceMode::Required,
458 )
459 .is_ok()
460 );
461
462 assert!(
463 ensure_headers_allowed(
464 Some("https://forge.example.com"),
465 None,
466 Some("same-origin"),
467 "http://127.0.0.1:3000",
468 &["https://forge.example.com".to_string()],
469 RequestSourceMode::Required,
470 )
471 .is_ok()
472 );
473 }
474 #[test]
475 fn same_site_fetch_metadata_requires_trusted_origin_or_referer() {
476 assert!(
477 ensure_headers_allowed(
478 Some("https://panel.example.com"),
479 None,
480 Some("same-site"),
481 "https://api.example.com",
482 &[
483 "https://api.example.com".to_string(),
484 "https://panel.example.com".to_string(),
485 ],
486 RequestSourceMode::OptionalWhenPresent,
487 )
488 .is_ok()
489 );
490
491 assert!(
492 ensure_headers_allowed(
493 None,
494 Some("https://panel.example.com/settings"),
495 Some("same-site"),
496 "https://api.example.com",
497 &[
498 "https://api.example.com".to_string(),
499 "https://panel.example.com".to_string(),
500 ],
501 RequestSourceMode::OptionalWhenPresent,
502 )
503 .is_ok()
504 );
505
506 let err = ensure_headers_allowed(
507 None,
508 None,
509 Some("same-site"),
510 "https://api.example.com",
511 &["https://api.example.com".to_string()],
512 RequestSourceMode::OptionalWhenPresent,
513 )
514 .unwrap_err();
515 assert_eq!(err.kind(), CsrfErrorKind::RequestSourceUntrusted);
516 assert!(err.message().contains("missing trusted request source"));
517 }
518 #[test]
519 fn rejects_untrusted_fetch_metadata_values() {
520 for fetch_site in ["cross-site", "none"] {
521 let err = ensure_headers_allowed(
522 None,
523 None,
524 Some(fetch_site),
525 "https://forge.example.com",
526 &[],
527 RequestSourceMode::OptionalWhenPresent,
528 )
529 .unwrap_err();
530 assert_eq!(err.kind(), CsrfErrorKind::RequestSourceUntrusted);
531 assert!(err.message().contains("untrusted request source"));
532 }
533 }
534 #[test]
535 fn rejects_untrusted_origin_and_missing_required_source() {
536 let err = ensure_headers_allowed(
537 Some("https://evil.example.com"),
538 None,
539 None,
540 "https://forge.example.com",
541 &[],
542 RequestSourceMode::OptionalWhenPresent,
543 )
544 .unwrap_err();
545 assert_eq!(err.kind(), CsrfErrorKind::RequestOriginUntrusted);
546
547 let err = ensure_headers_allowed(
548 None,
549 None,
550 None,
551 "https://forge.example.com",
552 &[],
553 RequestSourceMode::Required,
554 )
555 .unwrap_err();
556 assert_eq!(err.kind(), CsrfErrorKind::RequestSourceMissing);
557 }
558 #[test]
559 fn referer_source_check_ignores_long_path_after_bounded_origin() {
560 let long_referer = format!("https://forge.example.com/settings/{}", "a".repeat(10_000));
561
562 assert!(
563 ensure_headers_allowed(
564 None,
565 Some(&long_referer),
566 Some("same-origin"),
567 "https://forge.example.com",
568 &[],
569 RequestSourceMode::Required,
570 )
571 .is_ok()
572 );
573 }
574 #[test]
575 fn invalid_referer_missing_scheme_reports_invalid_scheme() {
576 let err = ensure_headers_allowed(
577 None,
578 Some("forge.example.com/settings"),
579 None,
580 "https://forge.example.com",
581 &[],
582 RequestSourceMode::OptionalWhenPresent,
583 )
584 .unwrap_err();
585
586 assert_eq!(err.kind(), CsrfErrorKind::RequestSchemeInvalid);
587 }
588 #[test]
589 fn accepts_missing_optional_source() {
590 assert!(
591 ensure_headers_allowed(
592 None,
593 None,
594 None,
595 "https://forge.example.com",
596 &[],
597 RequestSourceMode::OptionalWhenPresent,
598 )
599 .is_ok()
600 );
601 }
602 #[test]
603 fn build_csrf_token_returns_url_safe_random_value() {
604 let token_a = build_csrf_token();
605 let token_b = build_csrf_token();
606
607 assert_ne!(token_a, token_b);
608 assert!(token_a.len() >= 32);
609 assert!(
610 token_a
611 .chars()
612 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
613 );
614 }
615 #[test]
616 fn csrf_token_names_reject_invalid_cookie_and_header_names() {
617 let err = CsrfTokenNames::new("", "X-CSRF-Token").unwrap_err();
618 assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
619
620 let err = CsrfTokenNames::new("aster csrf", "X-CSRF-Token").unwrap_err();
621 assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
622
623 let err = CsrfTokenNames::new("aster_csrf", "bad header").unwrap_err();
624 assert_eq!(err.kind(), CsrfErrorKind::TokenNameInvalid);
625 }
626}