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" + ); +}