From 936f670de4e2b350b9946630b001877f8cc81b57 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 19:55:08 +0000 Subject: [PATCH 01/18] build: add csrf and embed feature flags --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index ce7fc50..1f69e09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ tower-sessions = ["dep:tower-sessions"] multipart = ["axum", "axum?/multipart", "dep:base64", "dep:bytes"] validator = ["dep:validator"] garde = ["dep:garde"] +csrf = ["axum", "dep:cookie", "dep:hmac", "dep:sha2", "dep:base64", "dep:getrandom"] +embed = ["axum"] ts = ["dep:ts-rs", "dep:inventory"] [dependencies] @@ -46,6 +48,7 @@ cookie = { version = "0.18", optional = true } hmac = { version = "0.13", optional = true } sha2 = { version = "0.11", optional = true } base64 = { version = "0.22", optional = true } +getrandom = { version = "0.3", optional = true } tower-sessions = { version = "0.15", optional = true } From ea699a73e80e24a931a8f34c2e3dbb9b1c86c07d Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 19:57:42 +0000 Subject: [PATCH 02/18] feat(csrf): signed double-submit token core --- src/csrf/mod.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 ++ 2 files changed, 132 insertions(+) create mode 100644 src/csrf/mod.rs diff --git a/src/csrf/mod.rs b/src/csrf/mod.rs new file mode 100644 index 0000000..68b8a99 --- /dev/null +++ b/src/csrf/mod.rs @@ -0,0 +1,127 @@ +//! Stateless, signed double-submit CSRF tokens (framework-agnostic). +//! +//! A token is `"{rand_b64}.{hmac(rand_b64)}"` using a URL-safe base64 alphabet, +//! so the value is safe to place in a cookie verbatim — axios reads it back and +//! echoes it in the `X-XSRF-TOKEN` header without `decodeURIComponent` mangling. +//! +//! Validation is double-submit: the request header must equal the cookie +//! (constant-time) AND the cookie must carry a valid server-issued signature. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// HMAC-SHA256 token minter/verifier. Clone-cheap (just holds the key). +#[derive(Clone)] +pub struct CsrfTokens { + key: Vec, +} + +impl CsrfTokens { + /// Create a minter. `key` must be at least 32 bytes (asserts, matching + /// [`crate::session::cookie::CookieSessionStore::new`]). + pub fn new(key: impl Into>) -> Self { + let key = key.into(); + assert!(key.len() >= 32, "veer csrf secret must be >= 32 bytes"); + Self { key } + } + + fn sign(&self, payload: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(&self.key).expect("hmac key"); + mac.update(payload); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + } + + /// Mint a fresh token: 32 random bytes, base64'd, with its signature. + pub fn generate(&self) -> String { + let mut rand = [0u8; 32]; + getrandom::fill(&mut rand).expect("getrandom"); + let rand_b64 = URL_SAFE_NO_PAD.encode(rand); + let sig = self.sign(rand_b64.as_bytes()); + format!("{rand_b64}.{sig}") + } + + /// True iff `token` is well-formed and carries a valid signature we issued. + pub fn is_valid(&self, token: &str) -> bool { + let Some((rand_b64, sig_b64)) = token.split_once('.') else { + return false; + }; + let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(sig_b64) else { + return false; + }; + let Ok(mut mac) = HmacSha256::new_from_slice(&self.key) else { + return false; + }; + mac.update(rand_b64.as_bytes()); + mac.verify_slice(&sig_bytes).is_ok() + } + + /// Double-submit check: header equals cookie (constant-time) AND the cookie + /// is a validly-signed token. + pub fn verify(&self, cookie_value: &str, header_value: &str) -> bool { + ct_eq(cookie_value.as_bytes(), header_value.as_bytes()) && self.is_valid(cookie_value) + } +} + +/// Constant-time byte-slice equality. Length difference short-circuits (the +/// length itself is not secret here). +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"0123456789012345678901234567890123456789"; + + #[test] + fn generate_then_verify_roundtrips() { + let t = CsrfTokens::new(KEY.to_vec()); + let token = t.generate(); + assert!(t.verify(&token, &token)); + assert!(t.is_valid(&token)); + } + + #[test] + fn tampered_signature_fails() { + let t = CsrfTokens::new(KEY.to_vec()); + let token = t.generate(); + let bad = format!("{token}x"); + assert!(!t.is_valid(&bad)); + assert!(!t.verify(&bad, &bad)); + } + + #[test] + fn token_from_other_key_fails() { + let a = CsrfTokens::new(KEY.to_vec()); + let b = CsrfTokens::new(b"abcdefghabcdefghabcdefghabcdefgh".to_vec()); + let token = a.generate(); + assert!(!b.verify(&token, &token)); + } + + #[test] + fn cookie_header_mismatch_fails_even_when_both_valid() { + let t = CsrfTokens::new(KEY.to_vec()); + let c = t.generate(); + let h = t.generate(); + assert!(t.is_valid(&c) && t.is_valid(&h)); + assert!(!t.verify(&c, &h)); + } + + #[test] + fn malformed_token_fails() { + let t = CsrfTokens::new(KEY.to_vec()); + assert!(!t.is_valid("no-dot")); + assert!(!t.verify("no-dot", "no-dot")); + } +} diff --git a/src/lib.rs b/src/lib.rs index e8f2e81..f06f6c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,9 @@ pub mod session; pub mod shared; pub mod ssr; +#[cfg(feature = "csrf")] +pub mod csrf; + #[cfg(feature = "multipart")] pub mod multipart; @@ -96,6 +99,8 @@ pub use response::InertiaResponse; pub use root_view::{MinimalRootView, RootView, RootViewContext, ViteManifest, ViteRootView}; pub use session::{Flash, SessionStore}; pub use shared::SharedProps; +#[cfg(feature = "csrf")] +pub use csrf::CsrfTokens; pub use ssr::{SsrClient, SsrPayload}; #[cfg(feature = "axum")] From 7738b2c1ef0d6e371da4543d7ed4e63d2c4a8c99 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:04:19 +0000 Subject: [PATCH 03/18] refactor(csrf): use subtle for constant-time compare; harden token tests --- Cargo.toml | 3 ++- src/csrf/mod.rs | 37 ++++++++++++++++++++++++------------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1f69e09..679f720 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ tower-sessions = ["dep:tower-sessions"] multipart = ["axum", "axum?/multipart", "dep:base64", "dep:bytes"] validator = ["dep:validator"] garde = ["dep:garde"] -csrf = ["axum", "dep:cookie", "dep:hmac", "dep:sha2", "dep:base64", "dep:getrandom"] +csrf = ["axum", "dep:cookie", "dep:hmac", "dep:sha2", "dep:base64", "dep:getrandom", "dep:subtle"] embed = ["axum"] ts = ["dep:ts-rs", "dep:inventory"] @@ -49,6 +49,7 @@ hmac = { version = "0.13", optional = true } sha2 = { version = "0.11", optional = true } base64 = { version = "0.22", optional = true } getrandom = { version = "0.3", optional = true } +subtle = { version = "2", optional = true } tower-sessions = { version = "0.15", optional = true } diff --git a/src/csrf/mod.rs b/src/csrf/mod.rs index 68b8a99..bf2f008 100644 --- a/src/csrf/mod.rs +++ b/src/csrf/mod.rs @@ -10,6 +10,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; +use subtle::ConstantTimeEq; type HmacSha256 = Hmac; @@ -44,6 +45,10 @@ impl CsrfTokens { } /// True iff `token` is well-formed and carries a valid signature we issued. + /// + /// Checks only the signature, not double-submit equality — prefer + /// [`Self::verify`] for CSRF validation. Exposed for callers that need a + /// standalone signature check (e.g. deciding whether to re-issue a cookie). pub fn is_valid(&self, token: &str) -> bool { let Some((rand_b64, sig_b64)) = token.split_once('.') else { return false; @@ -65,17 +70,11 @@ impl CsrfTokens { } } -/// Constant-time byte-slice equality. Length difference short-circuits (the -/// length itself is not secret here). +/// Constant-time byte-slice equality, backed by `subtle`. Unequal lengths are +/// not constant-time (the length itself is not secret here), but content +/// comparison is. fn ct_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - let mut diff = 0u8; - for (x, y) in a.iter().zip(b.iter()) { - diff |= x ^ y; - } - diff == 0 + a.ct_eq(b).into() } #[cfg(test)] @@ -96,9 +95,21 @@ mod tests { fn tampered_signature_fails() { let t = CsrfTokens::new(KEY.to_vec()); let token = t.generate(); - let bad = format!("{token}x"); - assert!(!t.is_valid(&bad)); - assert!(!t.verify(&bad, &bad)); + + // Length-changing tamper: extra char. + let longer = format!("{token}x"); + assert!(!t.is_valid(&longer)); + assert!(!t.verify(&longer, &longer)); + + // Same-length tamper: flip one base64 char inside the signature. + let (rand_b64, sig_b64) = token.split_once('.').unwrap(); + let mut sig: Vec = sig_b64.bytes().collect(); + let mid = sig.len() / 2; + sig[mid] = if sig[mid] == b'A' { b'B' } else { b'A' }; + let flipped = format!("{rand_b64}.{}", String::from_utf8(sig).unwrap()); + assert_eq!(flipped.len(), token.len()); + assert!(!t.is_valid(&flipped)); + assert!(!t.verify(&flipped, &flipped)); } #[test] From 0d415106ade17ede754f37182a24228ed50836c7 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:06:51 +0000 Subject: [PATCH 04/18] feat(csrf): standalone CsrfLayer tower middleware --- src/adapters/axum/csrf.rs | 187 ++++++++++++++++++++++++++++++++++++++ src/adapters/axum/mod.rs | 6 ++ src/lib.rs | 3 + tests/csrf.rs | 120 ++++++++++++++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 src/adapters/axum/csrf.rs create mode 100644 tests/csrf.rs diff --git a/src/adapters/axum/csrf.rs b/src/adapters/axum/csrf.rs new file mode 100644 index 0000000..e2d84e2 --- /dev/null +++ b/src/adapters/axum/csrf.rs @@ -0,0 +1,187 @@ +//! Standalone CSRF tower layer — stateless signed double-submit, axios/Inertia +//! compatible. Compose next to [`InertiaLayer`](super::InertiaLayer): +//! +//! ```ignore +//! .layer(InertiaLayer::new(cfg)).layer(CsrfLayer::new(secret)) +//! ``` +//! +//! On mutating methods it verifies the `X-XSRF-TOKEN` header against the +//! `XSRF-TOKEN` cookie (returning 419 on mismatch); on every response lacking a +//! valid token cookie it issues a fresh one (JS-readable, so axios can echo it). + +use crate::csrf::CsrfTokens; +use axum::body::Body; +use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; +use cookie::Cookie; +use http::header; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tower::{Layer, Service}; + +#[derive(Clone)] +struct CsrfConfig { + tokens: CsrfTokens, + secure: bool, + same_site: cookie::SameSite, + cookie_name: String, + header_name: String, + excludes: Vec, +} + +/// Tower layer adding double-submit CSRF protection. +#[derive(Clone)] +pub struct CsrfLayer { + config: CsrfConfig, +} + +impl CsrfLayer { + /// New layer. `secret` must be >= 32 bytes. Defaults: cookie `XSRF-TOKEN`, + /// header `x-xsrf-token`, `Secure` on, `SameSite=Lax`, no exclusions. + pub fn new(secret: impl Into>) -> Self { + Self { + config: CsrfConfig { + tokens: CsrfTokens::new(secret), + secure: true, + same_site: cookie::SameSite::Lax, + cookie_name: "XSRF-TOKEN".into(), + header_name: "x-xsrf-token".into(), + excludes: Vec::new(), + }, + } + } + + /// Toggle the cookie `Secure` flag (default `true`). Disable for local HTTP. + pub fn secure(mut self, yes: bool) -> Self { + self.config.secure = yes; + self + } + + /// Set the cookie `SameSite` attribute (default `Lax`). + pub fn same_site(mut self, s: cookie::SameSite) -> Self { + self.config.same_site = s; + self + } + + /// Override the cookie name (default `XSRF-TOKEN`). + pub fn cookie_name(mut self, name: impl Into) -> Self { + self.config.cookie_name = name.into(); + self + } + + /// Override the verified header name (default `x-xsrf-token`). + pub fn header_name(mut self, name: impl Into) -> Self { + self.config.header_name = name.into(); + self + } + + /// Skip verification for a path prefix (matches the path exactly or as a + /// `/`-bounded prefix). Repeatable. Use for webhook endpoints. + pub fn exclude(mut self, path: impl Into) -> Self { + self.config.excludes.push(path.into()); + self + } +} + +impl Layer for CsrfLayer { + type Service = CsrfMiddleware; + fn layer(&self, inner: S) -> Self::Service { + CsrfMiddleware { + inner, + config: Arc::new(self.config.clone()), + } + } +} + +#[doc(hidden)] +#[derive(Clone)] +pub struct CsrfMiddleware { + inner: S, + config: Arc, +} + +impl Service> for CsrfMiddleware +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let cfg = self.config.clone(); + let mut inner = self.inner.clone(); + Box::pin(async move { + let cookie_val = read_cookie(req.headers(), &cfg.cookie_name); + let cookie_is_valid = cookie_val + .as_deref() + .map(|c| cfg.tokens.is_valid(c)) + .unwrap_or(false); + + let is_mutating = matches!( + *req.method(), + Method::POST | Method::PUT | Method::PATCH | Method::DELETE + ); + let excluded = path_excluded(req.uri().path(), &cfg.excludes); + + if is_mutating && !excluded { + let header_val = req + .headers() + .get(cfg.header_name.as_str()) + .and_then(|v| v.to_str().ok()); + let ok = match (cookie_val.as_deref(), header_val) { + (Some(c), Some(h)) => cfg.tokens.verify(c, h), + _ => false, + }; + if !ok { + let mut resp = Response::new(Body::from("CSRF token mismatch")); + *resp.status_mut() = StatusCode::from_u16(419).unwrap(); + set_token_cookie(resp.headers_mut(), &cfg); + return Ok(resp); + } + } + + let mut resp = inner.call(req).await?; + if !cookie_is_valid { + set_token_cookie(resp.headers_mut(), &cfg); + } + Ok(resp) + }) + } +} + +fn path_excluded(path: &str, excludes: &[String]) -> bool { + excludes.iter().any(|p| { + let p = p.trim_end_matches('/'); + path == p || path.starts_with(&format!("{p}/")) + }) +} + +fn read_cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|hv| hv.to_str().ok()) + .flat_map(|s| s.split(';')) + .filter_map(|s| Cookie::parse(s.trim().to_owned()).ok()) + .find(|c| c.name() == name) + .map(|c| c.value().to_string()) +} + +fn set_token_cookie(headers: &mut HeaderMap, cfg: &CsrfConfig) { + let mut c = Cookie::new(cfg.cookie_name.clone(), cfg.tokens.generate()); + c.set_path("/"); + c.set_secure(cfg.secure); + c.set_same_site(cfg.same_site); + // Deliberately NOT http_only: axios reads the value from document.cookie. + if let Ok(hv) = http::HeaderValue::from_str(&c.to_string()) { + headers.append(header::SET_COOKIE, hv); + } +} diff --git a/src/adapters/axum/mod.rs b/src/adapters/axum/mod.rs index 62c946e..dc732f0 100644 --- a/src/adapters/axum/mod.rs +++ b/src/adapters/axum/mod.rs @@ -6,9 +6,15 @@ pub mod layer; pub mod response; pub mod router; +#[cfg(feature = "csrf")] +pub mod csrf; + pub use form::{InertiaForm, InertiaFormRejection}; pub use layer::InertiaLayer; pub use router::{Method, Router}; +#[cfg(feature = "csrf")] +pub use csrf::CsrfLayer; + #[cfg(feature = "multipart")] pub use form::MultipartStream; diff --git a/src/lib.rs b/src/lib.rs index f06f6c3..2934f5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,6 +106,9 @@ pub use ssr::{SsrClient, SsrPayload}; #[cfg(feature = "axum")] pub use adapters::axum::{InertiaForm, InertiaFormRejection, InertiaLayer, Method, Router}; +#[cfg(feature = "csrf")] +pub use adapters::axum::CsrfLayer; + #[cfg(feature = "multipart")] pub use multipart::UploadedFile; diff --git a/tests/csrf.rs b/tests/csrf.rs new file mode 100644 index 0000000..4362c74 --- /dev/null +++ b/tests/csrf.rs @@ -0,0 +1,120 @@ +#![cfg(feature = "csrf")] + +use axum::{ + body::Body, + routing::{get, post}, + Router, +}; +use http::Request; +use tower::ServiceExt; +use veer::CsrfLayer; + +const SECRET: &[u8] = b"0123456789012345678901234567890123456789"; + +fn app() -> Router { + Router::new() + .route("/", get(|| async { "ok" })) + .route("/submit", post(|| async { "done" })) + .layer(CsrfLayer::new(SECRET.to_vec()).secure(false)) +} + +fn req(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .unwrap() +} + +/// GET issues a JS-readable XSRF-TOKEN cookie, then return its value. +async fn token_from_get() -> String { + let resp = app().oneshot(req("GET", "/")).await.unwrap(); + let set = resp + .headers() + .get(http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + set.split(';') + .next() + .unwrap() + .split_once('=') + .unwrap() + .1 + .to_string() +} + +#[tokio::test] +async fn get_issues_readable_xsrf_cookie() { + let resp = app().oneshot(req("GET", "/")).await.unwrap(); + assert_eq!(resp.status(), 200); + let set = resp + .headers() + .get(http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(set.starts_with("XSRF-TOKEN=")); + assert!(!set.to_lowercase().contains("httponly")); +} + +#[tokio::test] +async fn post_with_matching_token_passes() { + let token = token_from_get().await; + let r = Request::builder() + .method("POST") + .uri("/submit") + .header(http::header::COOKIE, format!("XSRF-TOKEN={token}")) + .header("x-xsrf-token", &token) + .body(Body::empty()) + .unwrap(); + let resp = app().oneshot(r).await.unwrap(); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn post_without_token_is_419() { + let resp = app().oneshot(req("POST", "/submit")).await.unwrap(); + assert_eq!(resp.status(), 419); +} + +#[tokio::test] +async fn post_with_mismatched_token_is_419() { + let token = token_from_get().await; + let r = Request::builder() + .method("POST") + .uri("/submit") + .header(http::header::COOKIE, format!("XSRF-TOKEN={token}")) + .header("x-xsrf-token", "different.value") + .body(Body::empty()) + .unwrap(); + let resp = app().oneshot(r).await.unwrap(); + assert_eq!(resp.status(), 419); +} + +#[tokio::test] +async fn excluded_path_skips_verification() { + let app = Router::new() + .route("/webhooks/stripe", post(|| async { "ok" })) + .layer( + CsrfLayer::new(SECRET.to_vec()) + .secure(false) + .exclude("/webhooks"), + ); + let resp = app.oneshot(req("POST", "/webhooks/stripe")).await.unwrap(); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn valid_cookie_not_reissued() { + let token = token_from_get().await; + let r = Request::builder() + .method("GET") + .uri("/") + .header(http::header::COOKIE, format!("XSRF-TOKEN={token}")) + .body(Body::empty()) + .unwrap(); + let resp = app().oneshot(r).await.unwrap(); + assert!(resp.headers().get(http::header::SET_COOKIE).is_none()); +} From 0279710028d13fb4623f5a10b6c76e1e50af3b04 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:10:47 +0000 Subject: [PATCH 05/18] fix(csrf): don't rotate a valid token cookie on 419 --- src/adapters/axum/csrf.rs | 7 ++++++- tests/csrf.rs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/adapters/axum/csrf.rs b/src/adapters/axum/csrf.rs index e2d84e2..2167cef 100644 --- a/src/adapters/axum/csrf.rs +++ b/src/adapters/axum/csrf.rs @@ -143,7 +143,12 @@ where if !ok { let mut resp = Response::new(Body::from("CSRF token mismatch")); *resp.status_mut() = StatusCode::from_u16(419).unwrap(); - set_token_cookie(resp.headers_mut(), &cfg); + // Seed a token only if the client doesn't already hold a + // valid one — don't rotate a good cookie out from under a + // request whose header was merely missing/stale. + if !cookie_is_valid { + set_token_cookie(resp.headers_mut(), &cfg); + } return Ok(resp); } } diff --git a/tests/csrf.rs b/tests/csrf.rs index 4362c74..83d9a3e 100644 --- a/tests/csrf.rs +++ b/tests/csrf.rs @@ -118,3 +118,19 @@ async fn valid_cookie_not_reissued() { let resp = app().oneshot(r).await.unwrap(); assert!(resp.headers().get(http::header::SET_COOKIE).is_none()); } + +#[tokio::test] +async fn valid_cookie_wrong_header_is_419_and_keeps_cookie() { + let token = token_from_get().await; + let r = Request::builder() + .method("POST") + .uri("/submit") + .header(http::header::COOKIE, format!("XSRF-TOKEN={token}")) + .header("x-xsrf-token", "bogus.value") + .body(Body::empty()) + .unwrap(); + let resp = app().oneshot(r).await.unwrap(); + assert_eq!(resp.status(), 419); + // The existing cookie is still valid, so it must not be rotated. + assert!(resp.headers().get(http::header::SET_COOKIE).is_none()); +} From ae417eb012e17c8ed2a84d8922783d9673e50639 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:17:00 +0000 Subject: [PATCH 06/18] test(csrf): cover cookie seeding + PUT; drop per-request alloc in path_excluded --- src/adapters/axum/csrf.rs | 2 +- tests/csrf.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/adapters/axum/csrf.rs b/src/adapters/axum/csrf.rs index 2167cef..f5b3581 100644 --- a/src/adapters/axum/csrf.rs +++ b/src/adapters/axum/csrf.rs @@ -165,7 +165,7 @@ where fn path_excluded(path: &str, excludes: &[String]) -> bool { excludes.iter().any(|p| { let p = p.trim_end_matches('/'); - path == p || path.starts_with(&format!("{p}/")) + path == p || (path.starts_with(p) && path.as_bytes().get(p.len()) == Some(&b'/')) }) } diff --git a/tests/csrf.rs b/tests/csrf.rs index 83d9a3e..8b54377 100644 --- a/tests/csrf.rs +++ b/tests/csrf.rs @@ -77,6 +77,14 @@ async fn post_with_matching_token_passes() { async fn post_without_token_is_419() { let resp = app().oneshot(req("POST", "/submit")).await.unwrap(); assert_eq!(resp.status(), 419); + // A client with no cookie must be handed one so it can retry. + let set = resp + .headers() + .get(http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(set.starts_with("XSRF-TOKEN=")); } #[tokio::test] @@ -134,3 +142,11 @@ async fn valid_cookie_wrong_header_is_419_and_keeps_cookie() { // The existing cookie is still valid, so it must not be rotated. assert!(resp.headers().get(http::header::SET_COOKIE).is_none()); } + +#[tokio::test] +async fn put_without_token_is_419() { + // CsrfLayer intercepts before routing, so a tokenless mutating verb is + // rejected regardless of whether a matching route exists. + let resp = app().oneshot(req("PUT", "/submit")).await.unwrap(); + assert_eq!(resp.status(), 419); +} From f512e0cb30dde575a4e710a48e925601b7a59f45 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:19:11 +0000 Subject: [PATCH 07/18] feat(embed): EmbeddedAssets service for single-binary deploys --- src/adapters/axum/embed.rs | 130 +++++++++++++++++++++++++++++++++++++ src/adapters/axum/mod.rs | 6 ++ src/lib.rs | 3 + tests/embed.rs | 94 +++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/adapters/axum/embed.rs create mode 100644 tests/embed.rs diff --git a/src/adapters/axum/embed.rs b/src/adapters/axum/embed.rs new file mode 100644 index 0000000..e235c4d --- /dev/null +++ b/src/adapters/axum/embed.rs @@ -0,0 +1,130 @@ +//! Serve build assets embedded in the binary (rust-embed / `include_dir` / +//! a map) — the single-binary deploy counterpart to `ServeDir`. +//! +//! `EmbeddedAssets` is an infallible tower [`Service`] usable directly with +//! [`axum::Router::nest_service`]. It owns the boring parts (path stripping, +//! content-type, immutable cache headers, 404/405) and delegates byte lookup to +//! a resolver closure, so Veer depends on no embedding crate: +//! +//! ```ignore +//! #[derive(rust_embed::RustEmbed)] +//! #[folder = "dist/"] +//! struct Assets; +//! +//! router.nest_service("/build", EmbeddedAssets::new(|p| Assets::get(p).map(|f| f.data))) +//! ``` + +use axum::body::Body; +use axum::http::{header, Method, Request, Response, StatusCode}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::convert::Infallible; +use std::future::{ready, Ready}; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tower::Service; + +type Resolver = dyn Fn(&str) -> Option> + Send + Sync; + +/// Tower service that serves embedded assets via a byte-resolver closure. +#[derive(Clone)] +pub struct EmbeddedAssets { + resolver: Arc, + mimes: Arc>, +} + +impl EmbeddedAssets { + /// Construct from a resolver. The closure receives the request path with + /// the `nest_service` prefix already stripped and a leading `/` trimmed + /// (e.g. `assets/app-AAA.js`) and returns the bytes, or `None` for 404. + pub fn new(resolver: F) -> Self + where + F: Fn(&str) -> Option> + Send + Sync + 'static, + { + Self { + resolver: Arc::new(resolver), + mimes: Arc::new(HashMap::new()), + } + } + + /// Add or override an extension → content-type mapping (extension without + /// the dot, e.g. `.mime("wasm", "application/wasm")`). + pub fn mime(mut self, ext: impl Into, content_type: impl Into) -> Self { + Arc::make_mut(&mut self.mimes).insert(ext.into(), content_type.into()); + self + } + + fn content_type(&self, path: &str) -> String { + let ext = path.rsplit('.').next().unwrap_or(""); + if let Some(ct) = self.mimes.get(ext) { + return ct.clone(); + } + builtin_mime(ext).to_string() + } + + fn serve(&self, req: Request) -> Response { + if !matches!(*req.method(), Method::GET | Method::HEAD) { + return status(StatusCode::METHOD_NOT_ALLOWED); + } + let path = req.uri().path().trim_start_matches('/'); + match (self.resolver)(path) { + Some(bytes) => { + let ct = self.content_type(path); + let len = bytes.len(); + let body = if *req.method() == Method::HEAD { + Body::empty() + } else { + Body::from(bytes.into_owned()) + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, ct) + .header(header::CONTENT_LENGTH, len.to_string()) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .body(body) + .unwrap() + } + None => status(StatusCode::NOT_FOUND), + } + } +} + +impl Service> for EmbeddedAssets { + type Response = Response; + type Error = Infallible; + type Future = Ready, Infallible>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + ready(Ok(self.serve(req))) + } +} + +fn status(code: StatusCode) -> Response { + Response::builder().status(code).body(Body::empty()).unwrap() +} + +fn builtin_mime(ext: &str) -> &'static str { + match ext { + "js" | "mjs" => "text/javascript", + "css" => "text/css", + "html" | "htm" => "text/html; charset=utf-8", + "json" | "map" => "application/json", + "svg" => "image/svg+xml", + "wasm" => "application/wasm", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "ico" => "image/x-icon", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} diff --git a/src/adapters/axum/mod.rs b/src/adapters/axum/mod.rs index dc732f0..8664139 100644 --- a/src/adapters/axum/mod.rs +++ b/src/adapters/axum/mod.rs @@ -9,6 +9,9 @@ pub mod router; #[cfg(feature = "csrf")] pub mod csrf; +#[cfg(feature = "embed")] +pub mod embed; + pub use form::{InertiaForm, InertiaFormRejection}; pub use layer::InertiaLayer; pub use router::{Method, Router}; @@ -16,5 +19,8 @@ pub use router::{Method, Router}; #[cfg(feature = "csrf")] pub use csrf::CsrfLayer; +#[cfg(feature = "embed")] +pub use embed::EmbeddedAssets; + #[cfg(feature = "multipart")] pub use form::MultipartStream; diff --git a/src/lib.rs b/src/lib.rs index 2934f5e..4cf7877 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,6 +109,9 @@ pub use adapters::axum::{InertiaForm, InertiaFormRejection, InertiaLayer, Method #[cfg(feature = "csrf")] pub use adapters::axum::CsrfLayer; +#[cfg(feature = "embed")] +pub use adapters::axum::EmbeddedAssets; + #[cfg(feature = "multipart")] pub use multipart::UploadedFile; diff --git a/tests/embed.rs b/tests/embed.rs new file mode 100644 index 0000000..03982a3 --- /dev/null +++ b/tests/embed.rs @@ -0,0 +1,94 @@ +#![cfg(feature = "embed")] + +use axum::{body::Body, Router}; +use http::Request; +use http_body_util::BodyExt; +use std::borrow::Cow; +use tower::ServiceExt; +use veer::EmbeddedAssets; + +fn assets() -> EmbeddedAssets { + EmbeddedAssets::new(|p: &str| match p { + "assets/app-AAA.js" => Some(Cow::Borrowed(b"console.log(1)" as &[u8])), + _ => None, + }) +} + +fn app() -> Router { + Router::new().nest_service("/build", assets()) +} + +#[tokio::test] +async fn serves_known_asset_with_type_and_cache() { + let resp = app() + .oneshot( + Request::builder() + .uri("/build/assets/app-AAA.js") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers().get("content-type").unwrap(), "text/javascript"); + assert_eq!( + resp.headers().get("cache-control").unwrap(), + "public, max-age=31536000, immutable" + ); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"console.log(1)"); +} + +#[tokio::test] +async fn unknown_asset_is_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/build/nope.js") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); +} + +#[tokio::test] +async fn head_has_length_but_empty_body() { + let resp = app() + .oneshot( + Request::builder() + .method("HEAD") + .uri("/build/assets/app-AAA.js") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers().get("content-length").unwrap(), "14"); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert!(body.is_empty()); +} + +#[tokio::test] +async fn mime_override_takes_effect() { + let a = EmbeddedAssets::new(|p: &str| { + (p == "x.custom").then_some(Cow::Borrowed(b"hi" as &[u8])) + }) + .mime("custom", "application/x-custom"); + let app = Router::new().nest_service("/build", a); + let resp = app + .oneshot( + Request::builder() + .uri("/build/x.custom") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/x-custom" + ); +} From 6d805abc4920ceeb3aad8e6d5a90668c6b879b2b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:25:25 +0000 Subject: [PATCH 08/18] fix(embed): Allow header on 405, avoid copying borrowed asset bytes --- src/adapters/axum/embed.rs | 18 ++++++++++++++---- tests/embed.rs | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/adapters/axum/embed.rs b/src/adapters/axum/embed.rs index e235c4d..6eab6b9 100644 --- a/src/adapters/axum/embed.rs +++ b/src/adapters/axum/embed.rs @@ -16,6 +16,7 @@ use axum::body::Body; use axum::http::{header, Method, Request, Response, StatusCode}; +use bytes::Bytes; use std::borrow::Cow; use std::collections::HashMap; use std::convert::Infallible; @@ -48,7 +49,7 @@ impl EmbeddedAssets { } /// Add or override an extension → content-type mapping (extension without - /// the dot, e.g. `.mime("wasm", "application/wasm")`). + /// the dot, e.g. `.mime("vtt", "text/vtt")`). pub fn mime(mut self, ext: impl Into, content_type: impl Into) -> Self { Arc::make_mut(&mut self.mimes).insert(ext.into(), content_type.into()); self @@ -64,7 +65,11 @@ impl EmbeddedAssets { fn serve(&self, req: Request) -> Response { if !matches!(*req.method(), Method::GET | Method::HEAD) { - return status(StatusCode::METHOD_NOT_ALLOWED); + return Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header(header::ALLOW, "GET, HEAD") + .body(Body::empty()) + .unwrap(); } let path = req.uri().path().trim_start_matches('/'); match (self.resolver)(path) { @@ -74,12 +79,17 @@ impl EmbeddedAssets { let body = if *req.method() == Method::HEAD { Body::empty() } else { - Body::from(bytes.into_owned()) + // Avoid copying for the common rust-embed case where the + // bytes are 'static borrowed; only Owned needs a move. + match bytes { + Cow::Borrowed(b) => Body::from(Bytes::from_static(b)), + Cow::Owned(v) => Body::from(v), + } }; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, ct) - .header(header::CONTENT_LENGTH, len.to_string()) + .header(header::CONTENT_LENGTH, len) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .body(body) .unwrap() diff --git a/tests/embed.rs b/tests/embed.rs index 03982a3..7f5887b 100644 --- a/tests/embed.rs +++ b/tests/embed.rs @@ -92,3 +92,19 @@ async fn mime_override_takes_effect() { "application/x-custom" ); } + +#[tokio::test] +async fn non_get_head_method_is_405_with_allow_header() { + let resp = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/build/assets/app-AAA.js") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 405); + assert_eq!(resp.headers().get("allow").unwrap(), "GET, HEAD"); +} From 22c1365f0e55759133d065ff3e7b2062dc259596 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:26:58 +0000 Subject: [PATCH 09/18] docs: document csrf and embed features --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 ++ 2 files changed, 83 insertions(+) diff --git a/README.md b/README.md index 4f03b4a..ee4566f 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,85 @@ For SSR in production, build the sidecar with `vite build --ssr frontend/ssr.tsx +
+CSRF protection (Inertia/axios) + +The Inertia client uses axios, which reads an `XSRF-TOKEN` cookie and echoes it +back in an `X-XSRF-TOKEN` header on every mutating request — no frontend code +needed. `CsrfLayer` is the server side of that convention: it issues the cookie +and verifies the header using a stateless, HMAC-signed double-submit token (no +server-side session required). + +Enable the `csrf` feature and stack the layer next to `InertiaLayer`: + +```toml +veer = { version = "0.1", features = ["csrf"] } +``` + +```rust,ignore +use veer::{CsrfLayer, InertiaLayer}; + +let app = router() + .with_state(state) + .layer(InertiaLayer::new(cfg)) + .layer(CsrfLayer::new(secret)); // 32-byte secret; outermost layer +``` + +On a token mismatch the layer short-circuits with `419` (the Laravel/Inertia +convention for an expired token) before the handler runs. Safe methods +(GET/HEAD/OPTIONS/TRACE) are never checked. For endpoints that can't carry the +header (third-party webhooks), exclude them: + +```rust,ignore +CsrfLayer::new(secret).exclude("/webhooks") +``` + +The cookie is JS-readable by design (so axios can echo it); it is `Secure` + +`SameSite=Lax` by default — call `.secure(false)` for local HTTP dev. + +
+ +
+Embedded assets (single-binary deploy) + +For a single self-contained binary, embed the built frontend instead of serving +it from disk. The manifest embeds via `include_str!`; `EmbeddedAssets` serves the +bytes. Enable the `embed` feature: + +```toml +veer = { version = "0.1", features = ["embed"] } +rust-embed = "8" +``` + +```rust,ignore +use rust_embed::RustEmbed; +use veer::{EmbeddedAssets, ViteManifest, ViteRootView}; + +#[derive(RustEmbed)] +#[folder = "dist/"] +struct Assets; + +let manifest = ViteManifest::from_str(include_str!("../dist/.vite/manifest.json"))?; +let version = manifest.hash(); + +let cfg = InertiaConfig::new() + .version(move || version.clone().into()) + .root_view(ViteRootView::production().entry("frontend/app.tsx").manifest(manifest)); + +let app = router() + .with_state(state) + .layer(InertiaLayer::new(cfg)) + // Replaces `.nest_service("/build", ServeDir::new("dist"))`: + .nest_service("/build", EmbeddedAssets::new(|p| Assets::get(p).map(|f| f.data))); +``` + +`EmbeddedAssets` takes any `Fn(&str) -> Option>`, so it works +with `rust-embed`, `include_dir`, or a plain map — Veer depends on none of them. +It sets `Content-Type` from the file extension and serves content-hashed assets +with `Cache-Control: public, max-age=31536000, immutable`. + +
+
End-to-end TypeScript bindings (Wayfinder-style) @@ -483,6 +562,8 @@ Flash is stored under a single key (`_veer_flash` by default; override with `Tow | `tower-sessions` | off | Flash store backed by [`tower-sessions`](https://crates.io/crates/tower-sessions) | | `validator` | off | `IntoErrorBag` impl for `validator::ValidationErrors` | | `garde` | off | `IntoErrorBag` impl for `garde::Report` | +| `csrf` | off | Inertia/axios-compatible CSRF protection (`CsrfLayer`) | +| `embed` | off | Embedded-asset serving for single-binary deploys (`EmbeddedAssets`) | | `ts` | off | End-to-end TypeScript bindings codegen (`ts-rs` + `inventory`) | Disabling a feature drops its transitive deps entirely. diff --git a/src/lib.rs b/src/lib.rs index 4cf7877..ebc5144 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,8 @@ //! | `cookie-session` | off | Signed-cookie session store | //! | `validator` | off | `IntoErrorBag` impl for `validator::ValidationErrors` | //! | `garde` | off | `IntoErrorBag` impl for `garde::Report` | +//! | `csrf` | off | Inertia/axios-compatible CSRF layer (`CsrfLayer`) | +//! | `embed` | off | Embedded-asset serving service (`EmbeddedAssets`) | //! //! # Architecture //! From 79dfda98127dba6c1a979389207479b25ca20580 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:28:28 +0000 Subject: [PATCH 10/18] example: wire CsrfLayer into axum-react-todo --- examples/axum-react-todo/Cargo.toml | 2 +- examples/axum-react-todo/src/main.rs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/axum-react-todo/Cargo.toml b/examples/axum-react-todo/Cargo.toml index 903356f..e70c27e 100644 --- a/examples/axum-react-todo/Cargo.toml +++ b/examples/axum-react-todo/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -veer = { path = "../..", features = ["axum", "cookie-session", "validator", "ssr", "ts"] } +veer = { path = "../..", features = ["axum", "cookie-session", "csrf", "validator", "ssr", "ts"] } axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } diff --git a/examples/axum-react-todo/src/main.rs b/examples/axum-react-todo/src/main.rs index cabe202..eb2c679 100644 --- a/examples/axum-react-todo/src/main.rs +++ b/examples/axum-react-todo/src/main.rs @@ -1,8 +1,8 @@ use axum_react_todo::{router, todos::TodoStore}; use std::net::SocketAddr; use veer::{ - session::cookie::CookieSessionStore, ssr::http::HttpSsrClient, InertiaConfig, InertiaLayer, - ViteRootView, + session::cookie::CookieSessionStore, ssr::http::HttpSsrClient, CsrfLayer, InertiaConfig, + InertiaLayer, ViteRootView, }; #[tokio::main] @@ -44,10 +44,14 @@ async fn main() { cfg = cfg.csr_only(true); } + // CSRF protection (demo secret — load from config/env in production). + // Stacked outside InertiaLayer so it verifies before the handler runs and + // issues the XSRF-TOKEN cookie the Inertia/axios client echoes back. let app = router() .build() .with_state(store) - .layer(InertiaLayer::new(cfg)); + .layer(InertiaLayer::new(cfg)) + .layer(CsrfLayer::new(b"01234567890123456789012345678901".to_vec()).secure(false)); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); println!( From 29e9787495e6d386e6246a807dedc98cd86c3391 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:30:02 +0000 Subject: [PATCH 11/18] docs(changelog): add changelog with csrf + embed entries --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4550539 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `csrf` feature: `CsrfLayer`, a standalone tower layer providing + Inertia/axios-compatible CSRF protection via stateless HMAC-signed + double-submit tokens, plus the framework-agnostic `CsrfTokens` core. +- `embed` feature: `EmbeddedAssets`, an axum service for serving build assets + embedded in the binary (rust-embed / `include_dir` / map) — the single-binary + deploy counterpart to `ServeDir`. From 03ebd1f4a377761218d7cc4ce0261989499834b3 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 25 May 2026 20:33:59 +0000 Subject: [PATCH 12/18] docs: use .parse() in embed sample; test OPTIONS bypasses CSRF --- README.md | 2 +- tests/csrf.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee4566f..0b1c77f 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ use veer::{EmbeddedAssets, ViteManifest, ViteRootView}; #[folder = "dist/"] struct Assets; -let manifest = ViteManifest::from_str(include_str!("../dist/.vite/manifest.json"))?; +let manifest: ViteManifest = include_str!("../dist/.vite/manifest.json").parse()?; let version = manifest.hash(); let cfg = InertiaConfig::new() diff --git a/tests/csrf.rs b/tests/csrf.rs index 8b54377..2b9f3e4 100644 --- a/tests/csrf.rs +++ b/tests/csrf.rs @@ -150,3 +150,10 @@ async fn put_without_token_is_419() { let resp = app().oneshot(req("PUT", "/submit")).await.unwrap(); assert_eq!(resp.status(), 419); } + +#[tokio::test] +async fn options_request_is_not_verified() { + // Safe/non-mutating methods bypass CSRF verification entirely. + let resp = app().oneshot(req("OPTIONS", "/submit")).await.unwrap(); + assert_ne!(resp.status(), 419); +} From 67ce8f84edd6e330a23bdb3639c7093a8614eece Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 26 May 2026 03:21:43 +0000 Subject: [PATCH 13/18] fix(csrf,embed): address PR review findings - exclude(): normalize missing leading slash; ignore empty/"/" prefix instead of disabling CSRF site-wide (fail closed) + tests - CsrfTokens::is_valid is now pub(crate) so it can't be misused as a validation entry point that skips the cookie<->header binding - 419 response sets Content-Type: text/plain; charset=utf-8 - EmbeddedAssets: invalid .mime() override falls back to octet-stream instead of panicking the response builder + test - CsrfMiddleware: drive the poll_ready'd inner via mem::replace (Tower contract) rather than an unpolled clone - doc: note EmbeddedAssets assumes content-hashed filenames --- src/adapters/axum/csrf.rs | 26 ++++++++++++++++++++++++-- src/adapters/axum/embed.rs | 12 ++++++++++-- src/csrf/mod.rs | 10 ++++++---- tests/csrf.rs | 24 ++++++++++++++++++++++++ tests/embed.rs | 21 +++++++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/adapters/axum/csrf.rs b/src/adapters/axum/csrf.rs index f5b3581..cae571c 100644 --- a/src/adapters/axum/csrf.rs +++ b/src/adapters/axum/csrf.rs @@ -78,8 +78,17 @@ impl CsrfLayer { /// Skip verification for a path prefix (matches the path exactly or as a /// `/`-bounded prefix). Repeatable. Use for webhook endpoints. + /// + /// A missing leading `/` is added for you, so `exclude("webhooks")` and + /// `exclude("/webhooks")` are equivalent. An empty or `"/"` prefix is + /// ignored rather than excluding everything (which would silently disable + /// CSRF protection). pub fn exclude(mut self, path: impl Into) -> Self { - self.config.excludes.push(path.into()); + let mut p = path.into(); + if !p.starts_with('/') { + p.insert(0, '/'); + } + self.config.excludes.push(p); self } } @@ -117,7 +126,11 @@ where fn call(&mut self, req: Request) -> Self::Future { let cfg = self.config.clone(); - let mut inner = self.inner.clone(); + // Tower contract: drive the instance that poll_ready readied, leaving a + // fresh clone behind for the next call (rather than calling an unpolled + // clone). + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); Box::pin(async move { let cookie_val = read_cookie(req.headers(), &cfg.cookie_name); let cookie_is_valid = cookie_val @@ -143,6 +156,10 @@ where if !ok { let mut resp = Response::new(Body::from("CSRF token mismatch")); *resp.status_mut() = StatusCode::from_u16(419).unwrap(); + resp.headers_mut().insert( + header::CONTENT_TYPE, + http::HeaderValue::from_static("text/plain; charset=utf-8"), + ); // Seed a token only if the client doesn't already hold a // valid one — don't rotate a good cookie out from under a // request whose header was merely missing/stale. @@ -165,6 +182,11 @@ where fn path_excluded(path: &str, excludes: &[String]) -> bool { excludes.iter().any(|p| { let p = p.trim_end_matches('/'); + // An empty prefix would prefix-match every path and silently disable + // CSRF; treat it as matching nothing (fail closed). + if p.is_empty() { + return false; + } path == p || (path.starts_with(p) && path.as_bytes().get(p.len()) == Some(&b'/')) }) } diff --git a/src/adapters/axum/embed.rs b/src/adapters/axum/embed.rs index 6eab6b9..f3800a5 100644 --- a/src/adapters/axum/embed.rs +++ b/src/adapters/axum/embed.rs @@ -15,7 +15,7 @@ //! ``` use axum::body::Body; -use axum::http::{header, Method, Request, Response, StatusCode}; +use axum::http::{header, HeaderValue, Method, Request, Response, StatusCode}; use bytes::Bytes; use std::borrow::Cow; use std::collections::HashMap; @@ -28,6 +28,11 @@ use tower::Service; type Resolver = dyn Fn(&str) -> Option> + Send + Sync; /// Tower service that serves embedded assets via a byte-resolver closure. +/// +/// Responses carry `Cache-Control: public, max-age=31536000, immutable`, which +/// assumes Vite-style content-hashed filenames. Don't route non-fingerprinted +/// files (e.g. `robots.txt`, `manifest.webmanifest`) through this service — +/// they would be cached for a year with no revalidation path. #[derive(Clone)] pub struct EmbeddedAssets { resolver: Arc, @@ -74,7 +79,10 @@ impl EmbeddedAssets { let path = req.uri().path().trim_start_matches('/'); match (self.resolver)(path) { Some(bytes) => { - let ct = self.content_type(path); + // A bad `.mime()` override must not panic the builder; fall + // back to octet-stream if it isn't a valid header value. + let ct = HeaderValue::try_from(self.content_type(path)) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); let len = bytes.len(); let body = if *req.method() == Method::HEAD { Body::empty() diff --git a/src/csrf/mod.rs b/src/csrf/mod.rs index bf2f008..9725069 100644 --- a/src/csrf/mod.rs +++ b/src/csrf/mod.rs @@ -46,10 +46,12 @@ impl CsrfTokens { /// True iff `token` is well-formed and carries a valid signature we issued. /// - /// Checks only the signature, not double-submit equality — prefer - /// [`Self::verify`] for CSRF validation. Exposed for callers that need a - /// standalone signature check (e.g. deciding whether to re-issue a cookie). - pub fn is_valid(&self, token: &str) -> bool { + /// Checks only the signature, **not** the double-submit cookie↔header + /// binding, so it is not a CSRF check on its own — that is what + /// [`Self::verify`] is for. Kept crate-internal so it can't be mistaken for + /// a validation entry point; the layer uses it to decide whether to + /// re-issue a cookie. + pub(crate) fn is_valid(&self, token: &str) -> bool { let Some((rand_b64, sig_b64)) = token.split_once('.') else { return false; }; diff --git a/tests/csrf.rs b/tests/csrf.rs index 2b9f3e4..c15a9ce 100644 --- a/tests/csrf.rs +++ b/tests/csrf.rs @@ -157,3 +157,27 @@ async fn options_request_is_not_verified() { let resp = app().oneshot(req("OPTIONS", "/submit")).await.unwrap(); assert_ne!(resp.status(), 419); } + +#[tokio::test] +async fn exclude_root_does_not_disable_csrf() { + // `.exclude("/")` must not silently turn protection off for everything. + let app = Router::new() + .route("/submit", post(|| async { "done" })) + .layer(CsrfLayer::new(SECRET.to_vec()).secure(false).exclude("/")); + let resp = app.oneshot(req("POST", "/submit")).await.unwrap(); + assert_eq!(resp.status(), 419); +} + +#[tokio::test] +async fn exclude_without_leading_slash_still_matches() { + // `.exclude("webhooks")` is normalized to `/webhooks`. + let app = Router::new() + .route("/webhooks/stripe", post(|| async { "ok" })) + .layer( + CsrfLayer::new(SECRET.to_vec()) + .secure(false) + .exclude("webhooks"), + ); + let resp = app.oneshot(req("POST", "/webhooks/stripe")).await.unwrap(); + assert_eq!(resp.status(), 200); +} diff --git a/tests/embed.rs b/tests/embed.rs index 7f5887b..c21b57c 100644 --- a/tests/embed.rs +++ b/tests/embed.rs @@ -108,3 +108,24 @@ async fn non_get_head_method_is_405_with_allow_header() { assert_eq!(resp.status(), 405); assert_eq!(resp.headers().get("allow").unwrap(), "GET, HEAD"); } + +#[tokio::test] +async fn invalid_mime_override_falls_back_instead_of_panicking() { + let a = EmbeddedAssets::new(|p: &str| (p == "x.bad").then_some(Cow::Borrowed(b"hi" as &[u8]))) + .mime("bad", "text/bad\r\ninjected: 1"); + let app = Router::new().nest_service("/build", a); + let resp = app + .oneshot( + Request::builder() + .uri("/build/x.bad") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/octet-stream" + ); +} From 0c005afe2f552aa1276492eb08a107ca5ff24e27 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 26 May 2026 05:31:15 +0000 Subject: [PATCH 14/18] chore: release 0.1.2 (csrf + embed features) --- CHANGELOG.md | 5 +++++ Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4550539..6700e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2] - 2026-05-26 + ### Added - `csrf` feature: `CsrfLayer`, a standalone tower layer providing @@ -15,3 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `embed` feature: `EmbeddedAssets`, an axum service for serving build assets embedded in the binary (rust-embed / `include_dir` / map) — the single-binary deploy counterpart to `ServeDir`. + +[Unreleased]: https://github.com/Climactic/Veer/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/Climactic/Veer/compare/v0.1.1...v0.1.2 diff --git a/Cargo.toml b/Cargo.toml index 679f720..9ca6948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "veer" -version = "0.1.1" +version = "0.1.2" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" From 69090c65ed591066caf044d6fafa9b1013621a59 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 26 May 2026 06:03:32 +0000 Subject: [PATCH 15/18] fix(example): add default-run and migrate route to axum 0.8 path syntax - default-run = "axum-react-todo" so `cargo run` / `just dev` aren't ambiguous with the gen-bindings binary - /todos/:id -> /todos/{id}: axum 0.8 rejects the 0.7 colon syntax at runtime (panic on router build); the server now boots Both pre-existing, unrelated to the csrf/embed features. --- examples/axum-react-todo/Cargo.toml | 3 +++ examples/axum-react-todo/src/lib.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/axum-react-todo/Cargo.toml b/examples/axum-react-todo/Cargo.toml index e70c27e..4fd8729 100644 --- a/examples/axum-react-todo/Cargo.toml +++ b/examples/axum-react-todo/Cargo.toml @@ -3,6 +3,9 @@ name = "axum-react-todo" version = "0.0.1" edition = "2021" publish = false +# Two binaries live here (the server + the `gen-bindings` codegen tool), so +# `cargo run` / `just dev` need a default; `cargo run --bin gen-bindings` still works. +default-run = "axum-react-todo" [dependencies] veer = { path = "../..", features = ["axum", "cookie-session", "csrf", "validator", "ssr", "ts"] } diff --git a/examples/axum-react-todo/src/lib.rs b/examples/axum-react-todo/src/lib.rs index cb3ecac..f62b42b 100644 --- a/examples/axum-react-todo/src/lib.rs +++ b/examples/axum-react-todo/src/lib.rs @@ -14,7 +14,7 @@ pub fn router() -> veer::Router { .named_route(GET, "todos.index", "/todos", todos_index) .named_route(POST, "todos.store", "/todos", todos_create) .named_route(GET, "todos.create", "/todos/new", todos_new) - .named_route(DELETE, "todos.destroy", "/todos/:id", todos_delete) + .named_route(DELETE, "todos.destroy", "/todos/{id}", todos_delete) } async fn home(inertia: Inertia) -> impl axum::response::IntoResponse { From 55718afd3901a3643077c594c9178137d858ba07 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 26 May 2026 06:15:07 +0000 Subject: [PATCH 16/18] fix(bindings): parse axum 0.8 path params ({id}/{*rest}) The ts-bindings codegen extracted route params with the axum 0.7 colon/star syntax (:id, *rest), so under the axum 0.8 dependency a {id} route yielded a param-less URL helper. Add a shared segment_param() helper used by both parse_params() and write_url_template(), so the type signature and the URL template stay in sync, and cover it with a unit test. --- src/bindings/routes.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/bindings/routes.rs b/src/bindings/routes.rs index 0e97db6..b9f86ad 100644 --- a/src/bindings/routes.rs +++ b/src/bindings/routes.rs @@ -32,7 +32,7 @@ pub struct RouteEntry { /// Dotted name (e.g. `"users.show"`). The first segment becomes the /// controller (module / namespace); the remainder is the action. pub name: &'static str, - /// Axum path pattern (e.g. `"/users/:id"` or `"/files/*rest"`). + /// Axum path pattern (e.g. `"/users/{id}"` or `"/files/{*rest}"`). pub path: &'static str, /// HTTP method as a lowercase string (`"get"`, `"post"`, …). pub method: &'static str, @@ -223,9 +223,7 @@ fn write_url_template(s: &mut String, path: &str, params: &[&str]) { continue; } s.push('/'); - if let Some(name) = seg.strip_prefix(':') { - let _ = write!(s, "${{params.{name}}}"); - } else if let Some(name) = seg.strip_prefix('*') { + if let Some(name) = segment_param(seg) { let _ = write!(s, "${{params.{name}}}"); } else { s.push_str(seg); @@ -234,10 +232,16 @@ fn write_url_template(s: &mut String, path: &str, params: &[&str]) { s.push('`'); } +/// Extract the capture name from one axum 0.8 path segment: `{id}` → `"id"`, +/// `{*rest}` → `"rest"`. Returns `None` for literal segments. (axum 0.7 used +/// `:id` / `*rest`, which 0.8 rejects at router-build time.) +fn segment_param(seg: &str) -> Option<&str> { + let inner = seg.strip_prefix('{')?.strip_suffix('}')?; + Some(inner.strip_prefix('*').unwrap_or(inner)) +} + fn parse_params(path: &str) -> Vec<&str> { - path.split('/') - .filter_map(|seg| seg.strip_prefix(':').or_else(|| seg.strip_prefix('*'))) - .collect() + path.split('/').filter_map(segment_param).collect() } fn is_ident(name: &str) -> bool { @@ -331,3 +335,17 @@ fn js_ident(name: &str) -> String { base } } + +#[cfg(test)] +mod tests { + use super::parse_params; + + #[test] + fn parses_axum_08_path_params() { + assert_eq!(parse_params("/todos/{id}"), vec!["id"]); + assert_eq!(parse_params("/users/{id}/posts/{post}"), vec!["id", "post"]); + assert_eq!(parse_params("/files/{*rest}"), vec!["rest"]); + assert!(parse_params("/todos").is_empty()); + assert!(parse_params("/todos/new").is_empty()); + } +} From 1d8903de859828852cf57b8f94a5505df20dee2f Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 26 May 2026 06:15:07 +0000 Subject: [PATCH 17/18] style: apply rustfmt (fixes CI fmt --check) --- src/adapters/axum/embed.rs | 5 ++++- src/lib.rs | 4 ++-- tests/embed.rs | 12 +++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/adapters/axum/embed.rs b/src/adapters/axum/embed.rs index f3800a5..14d90de 100644 --- a/src/adapters/axum/embed.rs +++ b/src/adapters/axum/embed.rs @@ -122,7 +122,10 @@ impl Service> for EmbeddedAssets { } fn status(code: StatusCode) -> Response { - Response::builder().status(code).body(Body::empty()).unwrap() + Response::builder() + .status(code) + .body(Body::empty()) + .unwrap() } fn builtin_mime(ext: &str) -> &'static str { diff --git a/src/lib.rs b/src/lib.rs index ebc5144..ca8645d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,6 +92,8 @@ pub mod __private { } pub use config::InertiaConfig; +#[cfg(feature = "csrf")] +pub use csrf::CsrfTokens; pub use error::VeerError; pub use inertia::Inertia; pub use page::PageObject; @@ -101,8 +103,6 @@ pub use response::InertiaResponse; pub use root_view::{MinimalRootView, RootView, RootViewContext, ViteManifest, ViteRootView}; pub use session::{Flash, SessionStore}; pub use shared::SharedProps; -#[cfg(feature = "csrf")] -pub use csrf::CsrfTokens; pub use ssr::{SsrClient, SsrPayload}; #[cfg(feature = "axum")] diff --git a/tests/embed.rs b/tests/embed.rs index c21b57c..c436d52 100644 --- a/tests/embed.rs +++ b/tests/embed.rs @@ -30,7 +30,10 @@ async fn serves_known_asset_with_type_and_cache() { .await .unwrap(); assert_eq!(resp.status(), 200); - assert_eq!(resp.headers().get("content-type").unwrap(), "text/javascript"); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "text/javascript" + ); assert_eq!( resp.headers().get("cache-control").unwrap(), "public, max-age=31536000, immutable" @@ -73,10 +76,9 @@ async fn head_has_length_but_empty_body() { #[tokio::test] async fn mime_override_takes_effect() { - let a = EmbeddedAssets::new(|p: &str| { - (p == "x.custom").then_some(Cow::Borrowed(b"hi" as &[u8])) - }) - .mime("custom", "application/x-custom"); + let a = + EmbeddedAssets::new(|p: &str| (p == "x.custom").then_some(Cow::Borrowed(b"hi" as &[u8]))) + .mime("custom", "application/x-custom"); let app = Router::new().nest_service("/build", a); let resp = app .oneshot( From 9f4c555e25caff5245d28aea46a05ac68af8e90b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 26 May 2026 06:43:06 +0000 Subject: [PATCH 18/18] fix(example): wait for SSR sidecar before starting backend in just dev SSR mode runs the backend with ssr_required(true). just dev launched the Bun SSR sidecar and cargo run in parallel, so on a warm build the backend could take the first request before the sidecar finished booting and return 'ssr transport: error sending request'. Gate the backend start on the sidecar's /health endpoint. --- examples/axum-react-todo/justfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/axum-react-todo/justfile b/examples/axum-react-todo/justfile index 31c940d..d2f4ce3 100644 --- a/examples/axum-react-todo/justfile +++ b/examples/axum-react-todo/justfile @@ -18,6 +18,14 @@ dev: bun dev & if [ "${SSR:-}" = "1" ]; then bun run ssr:dev & + # The backend runs with ssr_required(true), so a request that arrives + # before the sidecar is ready fails with "ssr transport: error sending + # request". Wait for the sidecar's /health to respond before starting it. + echo "waiting for SSR sidecar on :13714..." + for _ in {1..100}; do + if curl -sf -o /dev/null http://127.0.0.1:13714/health 2>/dev/null; then echo "SSR sidecar ready"; break; fi + sleep 0.1 + done fi SSR="${SSR:-}" cargo run & wait