use std::convert::Infallible;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::extract::Request;
use axum::http::{HeaderValue, Method, Response, StatusCode};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use rand::RngCore;
use tower::Service;
pub const CSRF_COOKIE: &str = "rustango_csrf";
const CSRF_HEADER: &str = "X-CSRF-Token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
pub fn layer() -> CsrfLayer {
CsrfLayer::new(CsrfConfig::default())
}
pub fn with_config(cfg: CsrfConfig) -> CsrfLayer {
CsrfLayer::new(cfg)
}
#[derive(Debug, Clone)]
pub struct CsrfConfig {
pub cookie_name: String,
pub header_name: String,
pub secure: bool,
pub trusted_origins: Vec<String>,
pub exempt_prefixes: Vec<String>,
pub require_csrf_on_query: bool,
}
impl Default for CsrfConfig {
fn default() -> Self {
Self {
cookie_name: CSRF_COOKIE.to_owned(),
header_name: CSRF_HEADER.to_owned(),
secure: true,
trusted_origins: Vec::new(),
exempt_prefixes: Vec::new(),
require_csrf_on_query: false,
}
}
}
impl CsrfConfig {
#[must_use]
pub fn allow_insecure_for_dev(mut self) -> Self {
self.secure = false;
self
}
#[must_use]
pub fn trust_origin(mut self, origin: impl Into<String>) -> Self {
self.trusted_origins.push(origin.into());
self
}
#[must_use]
pub fn with_trusted_origins<I, S>(mut self, origins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.trusted_origins = origins.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn exempt_prefix(mut self, prefix: impl Into<String>) -> Self {
self.exempt_prefixes.push(prefix.into());
self
}
}
fn split_origin(origin: &str) -> Option<(&str, &str)> {
let (scheme, rest) = origin.split_once("://")?;
if scheme.is_empty() || rest.is_empty() {
return None;
}
Some((scheme, rest))
}
fn request_is_https(h: &axum::http::HeaderMap, uri_is_https: bool) -> bool {
if uri_is_https {
return true;
}
if h.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(',')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("https")
})
{
return true;
}
h.get("forwarded")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(';')
.flat_map(|p| p.split(','))
.filter_map(|p| p.trim().strip_prefix("proto="))
.any(|p| p.trim_matches('"').eq_ignore_ascii_case("https"))
})
}
fn origin_allowed(req: &Request<Body>, trusted: &[String]) -> bool {
origin_allowed_in(
req.headers(),
req.uri().scheme_str() == Some("https"),
trusted,
)
}
fn origin_allowed_in(
headers: &axum::http::HeaderMap,
uri_is_https: bool,
trusted: &[String],
) -> bool {
let Some(origin) = headers
.get(axum::http::header::ORIGIN)
.and_then(|h| h.to_str().ok())
else {
return !request_is_https(headers, uri_is_https);
};
if let Some(host) = headers
.get(axum::http::header::HOST)
.and_then(|h| h.to_str().ok())
{
if let Some((scheme, origin_host)) = split_origin(origin) {
let host_matches = origin_host.trim_end_matches('/') == host;
let scheme_ok = !(scheme == "http" && request_is_https(headers, uri_is_https));
if host_matches && scheme_ok {
return true;
}
}
}
for entry in trusted {
let entry = entry.trim().trim_end_matches('/');
if entry == origin {
return true;
}
for scheme in ["https://", "http://"] {
let Some(wild) = entry
.strip_prefix(scheme)
.and_then(|e| e.strip_prefix("*."))
else {
continue;
};
let Some(authority) = origin.strip_prefix(scheme) else {
continue;
};
if wildcard_matches(authority, wild) {
return true;
}
}
}
false
}
fn wildcard_matches(authority: &str, wild: &str) -> bool {
let host = authority.split(':').next().unwrap_or(authority);
let wild_host = wild.split(':').next().unwrap_or(wild);
if host == wild_host {
return true;
}
host.strip_suffix(wild_host)
.is_some_and(|rest| rest.ends_with('.'))
}
#[derive(Clone)]
pub(crate) struct ActiveCsrf {
cfg: Arc<CsrfConfig>,
checked: bool,
}
#[cfg(feature = "template_views")]
#[must_use]
pub(crate) fn active_cookie_name(extensions: &axum::http::Extensions) -> &str {
extensions
.get::<ActiveCsrf>()
.map_or(CSRF_COOKIE, |a| a.cfg.cookie_name.as_str())
}
#[derive(Clone)]
pub struct CsrfLayer {
cfg: Arc<CsrfConfig>,
}
impl CsrfLayer {
fn new(cfg: CsrfConfig) -> Self {
Self { cfg: Arc::new(cfg) }
}
}
impl<S> tower::Layer<S> for CsrfLayer {
type Service = CsrfService<S>;
fn layer(&self, inner: S) -> Self::Service {
CsrfService {
inner,
cfg: Arc::clone(&self.cfg),
}
}
}
#[derive(Clone)]
pub struct CsrfService<S> {
inner: S,
cfg: Arc<CsrfConfig>,
}
impl<S> Service<Request<Body>> for CsrfService<S>
where
S: Service<Request<Body>, Response = Response<Body>, Error = Infallible>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = Response<Body>;
type Error = Infallible;
type Future =
Pin<Box<dyn std::future::Future<Output = Result<Response<Body>, Infallible>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let cfg = Arc::clone(&self.cfg);
let mut inner = self.inner.clone();
Box::pin(async move {
let mut req = req;
let active = req.extensions().get::<ActiveCsrf>().cloned();
let nested = active.is_some();
let cfg = match active {
Some(a) if a.checked => return inner.call(req).await,
Some(a) => Arc::new(CsrfConfig {
exempt_prefixes: Vec::new(),
..CsrfConfig::clone(&a.cfg)
}),
None => {
req.extensions_mut().insert(ActiveCsrf {
cfg: Arc::clone(&cfg),
checked: false,
});
cfg
}
};
let cookie_value = read_csrf_cookie(&req, &cfg.cookie_name);
let enforced = !method_is_csrf_exempt(req.method(), &cfg)
&& !path_is_exempt(req.uri().path(), &cfg.exempt_prefixes);
let mut req = if enforced {
if !origin_allowed(&req, &cfg.trusted_origins) {
return Ok(forbid_response(
"CSRF: request Origin not in CSRF_TRUSTED_ORIGINS \
and does not match Host",
));
}
let header_value = req
.headers()
.get(&cfg.header_name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
if let Some(h) = header_value {
if !tokens_match(cookie_value.as_deref(), Some(&h)) {
return Ok(forbid_response("CSRF token missing or mismatched"));
}
req
} else if is_form_encoded(&req) {
let (parts, body) = req.into_parts();
let bytes = match axum::body::to_bytes(body, BODY_BUFFER_LIMIT).await {
Ok(b) => b,
Err(_) => {
return Ok(forbid_response("CSRF: form body exceeded buffer limit"));
}
};
let form_token = read_form_field(&bytes, CSRF_FORM_FIELD);
if !tokens_match(cookie_value.as_deref(), form_token.as_deref()) {
return Ok(forbid_response("CSRF token missing or mismatched"));
}
Request::from_parts(parts, Body::from(bytes))
} else {
return Ok(forbid_response("CSRF token missing or mismatched"));
}
} else {
req
};
if enforced {
req.extensions_mut().insert(ActiveCsrf {
cfg: Arc::clone(&cfg),
checked: true,
});
}
let mut response = inner.call(req).await?;
if cookie_value.is_none()
&& !nested
&& !sets_cookie(&response, &cfg.cookie_name)
&& !is_publicly_cacheable(response.headers())
{
let token = mint_token();
let cookie_str = format!(
"{}={token}; Path=/; SameSite=Lax{}",
cfg.cookie_name,
if cfg.secure { "; Secure" } else { "" }
);
if let Ok(hv) = HeaderValue::from_str(&cookie_str) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, hv);
}
}
Ok(response)
})
}
}
const BODY_BUFFER_LIMIT: usize = 2 * 1024 * 1024;
fn path_is_exempt(path: &str, prefixes: &[String]) -> bool {
prefixes.iter().any(|p| path.starts_with(p.as_str()))
}
fn is_safe_method(m: &Method) -> bool {
matches!(
*m,
Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
)
}
fn method_is_csrf_exempt(m: &Method, cfg: &CsrfConfig) -> bool {
is_safe_method(m) || (!cfg.require_csrf_on_query && m.as_str() == "QUERY")
}
pub(crate) fn is_publicly_cacheable(headers: &axum::http::HeaderMap) -> bool {
headers
.get_all(axum::http::header::CACHE_CONTROL)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.any(|d| d.trim().eq_ignore_ascii_case("public"))
}
fn sets_cookie<B>(response: &Response<B>, name: &str) -> bool {
response
.headers()
.get_all(axum::http::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.split(';')
.next()
.and_then(|pair| pair.split_once('='))
.is_some_and(|(k, _)| k.trim() == name)
})
}
fn read_csrf_cookie(req: &Request<Body>, name: &str) -> Option<String> {
read_csrf_cookie_from_headers(req.headers(), name)
}
fn read_csrf_cookie_from_headers(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
crate::cookies::cookie_from_headers(headers, name).map(str::to_owned)
}
fn is_form_encoded(req: &Request<Body>) -> bool {
let Some(ct) = req
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
else {
return false;
};
let head = ct
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
head == "application/x-www-form-urlencoded"
}
fn read_form_field(body: &[u8], name: &str) -> Option<String> {
let s = std::str::from_utf8(body).ok()?;
for pair in s.split('&') {
let Some((k, v)) = pair.split_once('=') else {
continue;
};
let key = url_decode_strict(k)?;
if key == name {
return url_decode_strict(v);
}
}
None
}
use crate::url_codec::url_decode_strict;
fn mint_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn is_minted_token(t: &str) -> bool {
t.len() == 43
&& t.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
#[must_use]
pub fn ensure_token(
headers: &axum::http::HeaderMap,
cookie_name: &str,
) -> (String, Option<String>) {
if let Some(existing) = read_csrf_cookie_from_headers(headers, cookie_name) {
if is_minted_token(&existing) {
return (existing, None);
}
}
let token = mint_token();
let secure = if crate::session::secure_cookies() {
"; Secure"
} else {
""
};
let cookie = format!("{cookie_name}={token}; Path=/; SameSite=Lax{secure}");
(token, Some(cookie))
}
#[must_use]
pub fn verify_form_token(headers: &axum::http::HeaderMap, submitted: Option<&str>) -> bool {
if !origin_allowed_in(headers, false, &[]) {
return false;
}
tokens_match(
read_csrf_cookie_from_headers(headers, CSRF_COOKIE).as_deref(),
submitted,
)
}
fn tokens_match(cookie: Option<&str>, submitted: Option<&str>) -> bool {
match (cookie, submitted) {
(Some(c), Some(s)) if !c.is_empty() => {
crate::crypto::constant_time_compare(c.as_bytes(), s.as_bytes())
}
_ => false,
}
}
fn forbid_response(detail: &'static str) -> Response<Body> {
let mut response = Response::new(Body::from(detail));
*response.status_mut() = StatusCode::FORBIDDEN;
response
.headers_mut()
.insert("Content-Type", HeaderValue::from_static("text/plain"));
response
}
#[must_use]
pub fn csrf_input_html(token: &str) -> String {
let escaped = html_escape_attr(token);
format!(r#"<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escaped}">"#)
}
use crate::text::html_escape as html_escape_attr;
#[cfg(feature = "template_views")]
#[must_use]
pub fn stamp_into_context(
headers: &axum::http::HeaderMap,
ctx: &mut tera::Context,
) -> Option<String> {
stamp_named_into_context(headers, CSRF_COOKIE, ctx)
}
#[cfg(feature = "template_views")]
#[must_use]
pub fn stamp_named_into_context(
headers: &axum::http::HeaderMap,
cookie_name: &str,
ctx: &mut tera::Context,
) -> Option<String> {
let (token, set_cookie) = ensure_token(headers, cookie_name);
let html = csrf_input_html(&token);
ctx.insert("csrf_token", &token);
ctx.insert("csrf_input", &html);
set_cookie
}
#[cfg(feature = "template_views")]
pub fn register_csrf_filter(tera: &mut tera::Tera) {
tera.register_filter("csrf_input", csrf_input_filter);
}
#[cfg(feature = "template_views")]
fn csrf_input_filter(
value: &tera::Value,
_: &std::collections::HashMap<String, tera::Value>,
) -> tera::Result<tera::Value> {
if let Some(s) = value.as_str() {
Ok(tera::Value::String(csrf_input_html(s)))
} else {
Ok(value.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn post(origin: Option<&str>, host: &str, https: bool) -> Request<Body> {
let mut b = Request::builder().method(Method::POST).uri("/forms/submit");
if let Some(o) = origin {
b = b.header(axum::http::header::ORIGIN, o);
}
if https {
b = b.header("x-forwarded-proto", "https");
}
b.header(axum::http::header::HOST, host)
.body(Body::empty())
.unwrap()
}
#[test]
fn a_foreign_origin_is_rejected_with_the_default_config() {
let cfg = CsrfConfig::default();
assert!(cfg.trusted_origins.is_empty(), "the default is still []");
assert!(
!origin_allowed(
&post(Some("https://evil.example"), "app.example.com", true),
&cfg.trusted_origins
),
"a foreign Origin must be refused out of the box"
);
assert!(origin_allowed(
&post(Some("https://app.example.com"), "app.example.com", true),
&cfg.trusted_origins
));
}
#[test]
fn a_missing_origin_is_refused_over_tls_only() {
let empty: Vec<String> = Vec::new();
assert!(
!origin_allowed(&post(None, "app.example.com", true), &empty),
"no Origin over TLS must be refused"
);
assert!(
origin_allowed(&post(None, "app.example.com", false), &empty),
"no Origin over plain http stays allowed"
);
}
#[test]
fn a_wildcard_entry_matches_a_non_default_port() {
let trusted = vec!["https://*.example.com".to_owned()];
for origin in [
"https://sub.example.com:8443",
"https://sub.example.com",
"https://deep.sub.example.com:443",
] {
assert!(
origin_allowed(&post(Some(origin), "app.other", true), &trusted),
"`{origin}` should match the wildcard"
);
}
for origin in [
"https://sub.example.com.evil.test",
"https://notexample.com:8443",
] {
assert!(
!origin_allowed(&post(Some(origin), "app.other", true), &trusted),
"`{origin}` must not match the wildcard"
);
}
}
#[test]
fn safe_method_predicate() {
assert!(is_safe_method(&Method::GET));
assert!(is_safe_method(&Method::HEAD));
assert!(is_safe_method(&Method::OPTIONS));
assert!(!is_safe_method(&Method::POST));
assert!(!is_safe_method(&Method::PUT));
assert!(!is_safe_method(&Method::DELETE));
}
#[test]
fn query_method_is_csrf_exempt_by_default() {
let query = Method::from_bytes(b"QUERY").unwrap();
let cfg = CsrfConfig::default();
assert!(method_is_csrf_exempt(&query, &cfg));
assert!(!method_is_csrf_exempt(&Method::POST, &cfg));
}
#[test]
fn require_csrf_on_query_opts_query_back_in() {
let query = Method::from_bytes(b"QUERY").unwrap();
let cfg = CsrfConfig {
require_csrf_on_query: true,
..CsrfConfig::default()
};
assert!(!method_is_csrf_exempt(&query, &cfg));
assert!(method_is_csrf_exempt(&Method::GET, &cfg));
}
#[test]
fn exempt_prefix_matches_only_configured_paths() {
let prefixes = vec!["/__cms__/collect".to_owned()];
assert!(path_is_exempt("/__cms__/collect", &prefixes));
assert!(path_is_exempt("/__cms__/collect/extra", &prefixes));
assert!(!path_is_exempt("/__cms__/other", &prefixes));
assert!(!path_is_exempt("/forms/submit/1", &prefixes));
assert!(!path_is_exempt("/", &prefixes));
assert!(!path_is_exempt("/__cms__/collect", &[]));
}
#[test]
fn exempt_prefix_builder_appends() {
let cfg = CsrfConfig::default().exempt_prefix("/__cms__/collect");
assert_eq!(cfg.exempt_prefixes, vec!["/__cms__/collect".to_owned()]);
}
#[test]
fn ct_eq_matches_eq() {
assert!(crate::crypto::constant_time_compare(b"abc", b"abc"));
assert!(!crate::crypto::constant_time_compare(b"abc", b"abd"));
assert!(!crate::crypto::constant_time_compare(b"abc", b"abcd"));
assert!(crate::crypto::constant_time_compare(b"", b""));
}
#[test]
fn mint_token_is_base64url_no_pad() {
let t = mint_token();
assert_eq!(t.len(), 43);
assert!(!t.contains('='));
assert!(URL_SAFE_NO_PAD.decode(t.as_bytes()).is_ok());
}
#[test]
fn read_csrf_cookie_finds_named_pair() {
use axum::http::Request;
let req = Request::builder()
.header("cookie", "session=abc; rustango_csrf=hello; theme=dark")
.body(Body::empty())
.unwrap();
assert_eq!(
read_csrf_cookie(&req, "rustango_csrf").as_deref(),
Some("hello")
);
assert_eq!(read_csrf_cookie(&req, "missing").as_deref(), None);
}
#[test]
fn read_csrf_cookie_returns_none_when_no_header() {
use axum::http::Request;
let req = Request::builder().body(Body::empty()).unwrap();
assert_eq!(read_csrf_cookie(&req, "anything"), None);
}
#[test]
fn ensure_token_marks_the_cookie_secure_under_a_secure_policy() {
let ours = crate::session::set_secure_cookies(true);
if !ours && !crate::session::secure_cookies() {
return;
}
let headers = axum::http::HeaderMap::new();
let (_token, set_cookie) = ensure_token(&headers, CSRF_COOKIE);
let cookie = set_cookie.expect("no cookie in the request, so one is minted");
assert!(
cookie.contains("; Secure"),
"policy is secure, so the cookie must say so: {cookie}"
);
}
#[test]
fn ensure_token_replaces_a_planted_cookie() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
"rustango_csrf=</script><svg/onload=alert(1)>"
.parse()
.unwrap(),
);
let (token, set_cookie) = ensure_token(&headers, CSRF_COOKIE);
assert!(is_minted_token(&token), "{token}");
assert!(set_cookie.is_some(), "the planted cookie is overwritten");
let good = mint_token();
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
format!("rustango_csrf={good}").parse().unwrap(),
);
assert_eq!(ensure_token(&headers, CSRF_COOKIE), (good, None));
}
#[test]
fn is_form_encoded_recognizes_canonical_and_charset_variants() {
use axum::http::Request;
let req = Request::builder()
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::empty())
.unwrap();
assert!(is_form_encoded(&req));
let req = Request::builder()
.header(
"content-type",
"application/x-www-form-urlencoded; charset=UTF-8",
)
.body(Body::empty())
.unwrap();
assert!(is_form_encoded(&req));
let req = Request::builder()
.header("content-type", "multipart/form-data; boundary=---")
.body(Body::empty())
.unwrap();
assert!(!is_form_encoded(&req));
let req = Request::builder()
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
assert!(!is_form_encoded(&req));
let req = Request::builder().body(Body::empty()).unwrap();
assert!(!is_form_encoded(&req));
}
#[test]
fn read_form_field_extracts_named_value() {
let body = b"foo=bar&_csrf=tok123&other=baz";
assert_eq!(read_form_field(body, "_csrf").as_deref(), Some("tok123"));
assert_eq!(read_form_field(body, "foo").as_deref(), Some("bar"));
assert_eq!(read_form_field(body, "missing"), None);
}
#[test]
fn read_form_field_percent_decodes_value() {
let body = b"_csrf=abc%2Fxyz";
assert_eq!(read_form_field(body, "_csrf").as_deref(), Some("abc/xyz"));
}
#[test]
fn read_form_field_refuses_a_malformed_token() {
assert_eq!(read_form_field(b"_csrf=ab%ZZ", "_csrf"), None);
assert_eq!(read_form_field(b"_csrf=ab%2", "_csrf"), None);
}
#[test]
fn read_form_field_treats_plus_as_space() {
let body = b"q=hello+world";
assert_eq!(read_form_field(body, "q").as_deref(), Some("hello world"));
}
#[test]
fn read_form_field_returns_none_for_malformed_pairs() {
let body = b"foo&_csrf=tok";
assert_eq!(read_form_field(body, "_csrf").as_deref(), Some("tok"));
assert_eq!(read_form_field(body, "foo"), None);
}
#[test]
fn percent_decode_rejects_malformed() {
assert!(url_decode_strict("%2").is_none()); assert!(url_decode_strict("%ZZ").is_none()); assert!(url_decode_strict("%+5").is_none()); assert!(url_decode_strict("%FF").is_none()); assert_eq!(url_decode_strict("plain").as_deref(), Some("plain"));
assert_eq!(url_decode_strict("a%20b+c").as_deref(), Some("a b c"));
}
#[test]
fn csrf_input_html_uses_form_field_constant() {
let html = csrf_input_html("abc123");
assert_eq!(html, r#"<input type="hidden" name="_csrf" value="abc123">"#);
assert!(html.contains(&format!(r#"name="{CSRF_FORM_FIELD}""#)));
}
#[test]
fn verify_form_token_matches_cookie_constant_time() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
format!("{CSRF_COOKIE}=tok-abc123; other=x")
.parse()
.unwrap(),
);
assert!(verify_form_token(&headers, Some("tok-abc123")));
assert!(!verify_form_token(&headers, Some("tok-different")));
assert!(!verify_form_token(&headers, None));
let empty = axum::http::HeaderMap::new();
assert!(!verify_form_token(&empty, Some("tok-abc123")));
}
#[tokio::test]
async fn an_empty_cookie_and_empty_token_are_refused() {
use tower::ServiceExt as _;
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
format!("{CSRF_COOKIE}=").parse().unwrap(),
);
let mut passed = Vec::new();
if verify_form_token(&headers, Some("")) {
passed.push("verify_form_token");
}
let app = axum::Router::new()
.route("/save", axum::routing::post(|| async { "saved" }))
.layer(layer());
let send = |req: Request<Body>| app.clone().oneshot(req);
let base = || {
Request::builder()
.method("POST")
.uri("/save")
.header("cookie", "rustango_csrf=")
};
let by_header = base().header("x-csrf-token", "").body(Body::empty());
let by_form = base()
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf="));
for (name, req) in [("header", by_header.unwrap()), ("form", by_form.unwrap())] {
if send(req).await.unwrap().status() != StatusCode::FORBIDDEN {
passed.push(name);
}
}
assert!(passed.is_empty(), "empty tokens passed via {passed:?}");
}
#[test]
fn verify_form_token_checks_origin() {
let with = |origin: Option<&str>, proto: Option<&str>| {
let mut h = axum::http::HeaderMap::new();
h.insert(
axum::http::header::COOKIE,
format!("{CSRF_COOKIE}=tok").parse().unwrap(),
);
h.insert(axum::http::header::HOST, "t01.example.com".parse().unwrap());
if let Some(o) = origin {
h.insert(axum::http::header::ORIGIN, o.parse().unwrap());
}
if let Some(p) = proto {
h.insert("x-forwarded-proto", p.parse().unwrap());
}
verify_form_token(&h, Some("tok"))
};
assert!(!with(Some("http://t02.example.com"), None));
assert!(!with(Some("null"), None));
assert!(with(Some("http://t01.example.com"), None));
assert!(with(None, None), "plain http without Origin keeps working");
assert!(!with(None, Some("https")), "over TLS, Origin is required");
}
#[test]
fn csrf_input_html_escapes_attribute_specials() {
let html = csrf_input_html(r#"x"<&>'"#);
assert!(
!html.contains(r#"x"<&>'"#),
"raw specials must NOT survive: {html}"
);
assert!(html.contains("""), "{html}");
assert!(html.contains("<"), "{html}");
assert!(html.contains("&"), "{html}");
assert!(html.contains(">"), "{html}");
assert!(html.contains("'"), "{html}");
}
#[cfg(feature = "template_views")]
#[test]
fn stamp_into_context_stamps_both_keys_and_returns_set_cookie_on_fresh() {
let headers = axum::http::HeaderMap::new(); let mut ctx = tera::Context::new();
let set_cookie = stamp_into_context(&headers, &mut ctx);
assert!(set_cookie.is_some(), "fresh GET should return Set-Cookie");
let json = ctx.into_json();
let token = json
.get("csrf_token")
.and_then(|v| v.as_str())
.expect("csrf_token stamped");
let input_html = json
.get("csrf_input")
.and_then(|v| v.as_str())
.expect("csrf_input stamped");
assert!(!token.is_empty(), "minted token shouldn't be empty");
assert!(
input_html.contains(token),
"csrf_input should embed the token: {input_html}"
);
}
#[cfg(feature = "template_views")]
#[test]
fn stamp_into_context_reuses_existing_cookie_no_set_cookie() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static(
"rustango_csrf=fixed_value-fixed_value-fixed_value-fixed_v",
),
);
let mut ctx = tera::Context::new();
let set_cookie = stamp_into_context(&headers, &mut ctx);
assert!(set_cookie.is_none(), "existing cookie should not re-mint");
let json = ctx.into_json();
assert_eq!(
json.get("csrf_token").and_then(|v| v.as_str()),
Some("fixed_value-fixed_value-fixed_value-fixed_v")
);
}
#[cfg(feature = "template_views")]
#[test]
fn csrf_input_filter_converts_token_to_html() {
let mut tera = tera::Tera::default();
register_csrf_filter(&mut tera);
tera.add_raw_template("_", "{{ csrf_token | csrf_input | safe }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("csrf_token", "tok-abc");
let out = tera.render("_", &ctx).unwrap();
assert_eq!(out, r#"<input type="hidden" name="_csrf" value="tok-abc">"#);
}
#[cfg(feature = "template_views")]
#[test]
fn csrf_input_filter_passes_through_non_string_values() {
let mut tera = tera::Tera::default();
register_csrf_filter(&mut tera);
tera.add_raw_template("_", "{{ n | csrf_input }}").unwrap();
let mut ctx = tera::Context::new();
ctx.insert("n", &42_i64);
assert_eq!(tera.render("_", &ctx).unwrap(), "42");
}
#[test]
fn sets_cookie_spots_the_handlers_own_token() {
let resp = axum::http::Response::builder()
.header(axum::http::header::SET_COOKIE, "rustango_csrf=abc; Path=/")
.body(())
.unwrap();
assert!(sets_cookie(&resp, "rustango_csrf"));
assert!(!sets_cookie(&resp, "other_cookie"));
}
#[tokio::test]
async fn no_cookie_is_seeded_on_a_public_cacheable_response() {
use tower::ServiceExt as _;
let app = axum::Router::new()
.route(
"/asset",
axum::routing::get(|| async { ([("cache-control", "public, max-age=300")], "x") }),
)
.route("/page", axum::routing::get(|| async { "x" }))
.layer(with_config(CsrfConfig::default().allow_insecure_for_dev()));
let get = |uri: &'static str| {
app.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
};
assert!(get("/asset")
.await
.unwrap()
.headers()
.get("set-cookie")
.is_none());
assert!(get("/page")
.await
.unwrap()
.headers()
.get("set-cookie")
.is_some());
}
#[tokio::test]
async fn a_large_form_with_a_valid_token_passes() {
use tower::ServiceExt as _;
let app = axum::Router::new()
.route("/save", axum::routing::post(|| async { "saved" }))
.layer(with_config(CsrfConfig::default().allow_insecure_for_dev()));
let body = format!("_csrf=tok&text={}", "a".repeat(200 * 1024));
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/save")
.header("cookie", "rustango_csrf=tok")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn sets_cookie_is_false_when_the_response_sets_none() {
let resp = axum::http::Response::builder().body(()).unwrap();
assert!(!sets_cookie(&resp, "rustango_csrf"));
}
#[test]
fn sets_cookie_ignores_a_different_cookie_with_a_similar_prefix() {
let resp = axum::http::Response::builder()
.header(axum::http::header::SET_COOKIE, "rustango_csrf_other=abc")
.header(axum::http::header::SET_COOKIE, "session=xyz; HttpOnly")
.body(())
.unwrap();
assert!(!sets_cookie(&resp, "rustango_csrf"));
}
}