diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6700e8e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,22 @@
+# 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]
+
+## [0.1.2] - 2026-05-26
+
+### 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`.
+
+[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 ce7fc50..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"
@@ -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", "dep:subtle"]
+embed = ["axum"]
ts = ["dep:ts-rs", "dep:inventory"]
[dependencies]
@@ -46,6 +48,8 @@ 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 }
+subtle = { version = "2", optional = true }
tower-sessions = { version = "0.15", optional = true }
diff --git a/README.md b/README.md
index 4f03b4a..0b1c77f 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 = include_str!("../dist/.vite/manifest.json").parse()?;
+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/examples/axum-react-todo/Cargo.toml b/examples/axum-react-todo/Cargo.toml
index 903356f..4fd8729 100644
--- a/examples/axum-react-todo/Cargo.toml
+++ b/examples/axum-react-todo/Cargo.toml
@@ -3,9 +3,12 @@ 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", "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/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
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 {
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!(
diff --git a/src/adapters/axum/csrf.rs b/src/adapters/axum/csrf.rs
new file mode 100644
index 0000000..cae571c
--- /dev/null
+++ b/src/adapters/axum/csrf.rs
@@ -0,0 +1,214 @@
+//! 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.
+ ///
+ /// 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 {
+ let mut p = path.into();
+ if !p.starts_with('/') {
+ p.insert(0, '/');
+ }
+ self.config.excludes.push(p);
+ 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();
+ // 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
+ .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();
+ 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.
+ if !cookie_is_valid {
+ 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('/');
+ // 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'/'))
+ })
+}
+
+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/embed.rs b/src/adapters/axum/embed.rs
new file mode 100644
index 0000000..14d90de
--- /dev/null
+++ b/src/adapters/axum/embed.rs
@@ -0,0 +1,151 @@
+//! 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, HeaderValue, Method, Request, Response, StatusCode};
+use bytes::Bytes;
+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.
+///
+/// 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,
+ 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("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
+ }
+
+ 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 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) {
+ Some(bytes) => {
+ // 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()
+ } else {
+ // 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)
+ .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 62c946e..8664139 100644
--- a/src/adapters/axum/mod.rs
+++ b/src/adapters/axum/mod.rs
@@ -6,9 +6,21 @@ pub mod layer;
pub mod response;
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};
+#[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/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());
+ }
+}
diff --git a/src/csrf/mod.rs b/src/csrf/mod.rs
new file mode 100644
index 0000000..9725069
--- /dev/null
+++ b/src/csrf/mod.rs
@@ -0,0 +1,140 @@
+//! 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;
+use subtle::ConstantTimeEq;
+
+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.
+ ///
+ /// 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;
+ };
+ 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, 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 {
+ a.ct_eq(b).into()
+}
+
+#[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();
+
+ // 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]
+ 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..ca8645d 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
//!
@@ -69,6 +71,9 @@ pub mod session;
pub mod shared;
pub mod ssr;
+#[cfg(feature = "csrf")]
+pub mod csrf;
+
#[cfg(feature = "multipart")]
pub mod multipart;
@@ -87,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,6 +108,12 @@ 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 = "embed")]
+pub use adapters::axum::EmbeddedAssets;
+
#[cfg(feature = "multipart")]
pub use multipart::UploadedFile;
diff --git a/tests/csrf.rs b/tests/csrf.rs
new file mode 100644
index 0000000..c15a9ce
--- /dev/null
+++ b/tests/csrf.rs
@@ -0,0 +1,183 @@
+#![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);
+ // 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]
+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());
+}
+
+#[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());
+}
+
+#[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);
+}
+
+#[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);
+}
+
+#[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
new file mode 100644
index 0000000..c436d52
--- /dev/null
+++ b/tests/embed.rs
@@ -0,0 +1,133 @@
+#![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"
+ );
+}
+
+#[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");
+}
+
+#[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"
+ );
+}