rws_server/csrf/mod.rs
1//! CSRF protection middleware using the double-submit cookie pattern.
2//!
3//! Enabled by the `csrf` Cargo feature:
4//!
5//! ```toml
6//! rust-web-server = { version = "17", features = ["csrf"] }
7//! ```
8//!
9//! # Usage
10//!
11//! Add [`CsrfLayer`] to the middleware stack. It validates the CSRF token on
12//! every mutating request (`POST`, `PUT`, `PATCH`, `DELETE`) and passes safe
13//! methods through unconditionally.
14//!
15//! ```rust,no_run
16//! # extern crate rws_server as rust_web_server;
17//! use rust_web_server::app::App;
18//! use rust_web_server::core::New;
19//! use rust_web_server::csrf::CsrfLayer;
20//!
21//! let app = App::new().wrap(CsrfLayer::new());
22//! ```
23//!
24//! # Embedding the token in HTML forms
25//!
26//! In a `GET` handler, call [`CsrfToken::from_request`] to obtain the current
27//! token and embed it in the form:
28//!
29//! ```rust,no_run
30//! # extern crate rws_server as rust_web_server;
31//! use rust_web_server::core::New;
32//! use rust_web_server::csrf::CsrfToken;
33//! use rust_web_server::request::Request;
34//! use rust_web_server::response::Response;
35//! use rust_web_server::server::ConnectionInfo;
36//!
37//! fn show_form(req: &Request, _conn: &ConnectionInfo) -> Response {
38//! let token = CsrfToken::from_request(req)
39//! .map(|t| t.value().to_string())
40//! .unwrap_or_default();
41//! let html = format!(
42//! r#"<form method="POST" action="/submit">
43//! <input type="hidden" name="_csrf" value="{token}">
44//! <button type="submit">Submit</button>
45//! </form>"#
46//! );
47//! Response::new() // build your response with `html` body
48//! }
49//! ```
50//!
51//! For AJAX, include the token in the `X-CSRF-Token` request header. The cookie
52//! is not `HttpOnly` by default so that JavaScript can read it via
53//! `document.cookie`; call [`.http_only(true)`](CsrfLayer::http_only) to
54//! restrict to HTML-form workflows only.
55//!
56//! # How it works
57//!
58//! 1. On safe methods (`GET`/`HEAD`/`OPTIONS`): [`CsrfLayer`] reads the
59//! existing `_csrf` cookie or generates a 32-byte random token. The token is
60//! injected into the request (as an internal header) so [`CsrfToken::from_request`]
61//! can return it to the handler. The cookie is (re-)set on the response.
62//!
63//! 2. On mutating methods: the layer reads the cookie value and the submitted
64//! value (from the `X-CSRF-Token` header or the `_csrf` form field). It
65//! compares them in **constant time** and returns `403 Forbidden` if they do
66//! not match.
67
68#[cfg(test)]
69mod tests;
70
71use rand_core::{OsRng, RngCore};
72
73use crate::application::Application;
74use crate::error::{AppError, IntoResponse};
75use crate::header::Header;
76use crate::middleware::Middleware;
77use crate::request::Request;
78use crate::response::Response;
79use crate::server::ConnectionInfo;
80
81/// Internal header name used to pass the current CSRF token from
82/// [`CsrfLayer`] to [`CsrfToken::from_request`] without a separate store.
83const INJECTED_HEADER: &str = "X-Rws-Csrf-Token";
84
85/// CSRF protection middleware.
86///
87/// Add to any application with `.wrap(CsrfLayer::new())`. Safe HTTP methods
88/// (`GET`, `HEAD`, `OPTIONS`, `TRACE`) always pass through; mutating methods
89/// are validated and rejected with `403 Forbidden` on mismatch.
90///
91/// # Defaults
92///
93/// | Setting | Default |
94/// |---|---|
95/// | Cookie name | `_csrf` |
96/// | Form field name | `_csrf` |
97/// | Validated header | `X-CSRF-Token` |
98/// | `SameSite` | `Strict` |
99/// | `HttpOnly` | `false` (JS-readable for AJAX workflows) |
100/// | `Secure` | `false` (enable in production behind HTTPS) |
101pub struct CsrfLayer {
102 cookie_name: String,
103 field_name: String,
104 header_name: String,
105 http_only: bool,
106 secure: bool,
107 same_site: String,
108}
109
110impl Default for CsrfLayer {
111 fn default() -> Self {
112 CsrfLayer {
113 cookie_name: "_csrf".to_string(),
114 field_name: "_csrf".to_string(),
115 header_name: "X-CSRF-Token".to_string(),
116 http_only: false,
117 secure: false,
118 same_site: "Strict".to_string(),
119 }
120 }
121}
122
123impl CsrfLayer {
124 /// Create a [`CsrfLayer`] with default settings.
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 /// Override the cookie name (default: `"_csrf"`).
130 pub fn cookie_name(mut self, name: &str) -> Self {
131 self.cookie_name = name.to_string();
132 self
133 }
134
135 /// Override the form field name (default: `"_csrf"`).
136 pub fn field_name(mut self, name: &str) -> Self {
137 self.field_name = name.to_string();
138 self
139 }
140
141 /// Override the request header name that carries the token for AJAX
142 /// (default: `"X-CSRF-Token"`).
143 pub fn header_name(mut self, name: &str) -> Self {
144 self.header_name = name.to_string();
145 self
146 }
147
148 /// Set the `HttpOnly` flag on the CSRF cookie (default: `false`).
149 ///
150 /// Enable this if you only serve traditional HTML forms and never need
151 /// JavaScript to read the token. When `true`, AJAX clients cannot access
152 /// the token via `document.cookie` and must rely on a server-rendered
153 /// token in the HTML.
154 pub fn http_only(mut self, http_only: bool) -> Self {
155 self.http_only = http_only;
156 self
157 }
158
159 /// Set the `Secure` flag on the CSRF cookie (default: `false`).
160 ///
161 /// Enable in production to prevent the cookie from being sent over
162 /// plain HTTP.
163 pub fn secure(mut self, secure: bool) -> Self {
164 self.secure = secure;
165 self
166 }
167
168 fn generate_token(&self) -> String {
169 let mut bytes = [0u8; 32];
170 OsRng.fill_bytes(&mut bytes);
171 bytes.iter().map(|b| format!("{:02x}", b)).collect()
172 }
173
174 fn get_cookie_token(&self, req: &Request) -> Option<String> {
175 get_cookie(req, &self.cookie_name)
176 }
177
178 fn get_submitted_token(&self, req: &Request) -> Option<String> {
179 for h in &req.headers {
180 if h.name.eq_ignore_ascii_case(&self.header_name) {
181 let val = h.value.trim().to_string();
182 if !val.is_empty() {
183 return Some(val);
184 }
185 }
186 }
187 let is_form = req.headers.iter().any(|h| {
188 h.name.eq_ignore_ascii_case("content-type")
189 && h.value
190 .to_lowercase()
191 .contains("application/x-www-form-urlencoded")
192 });
193 if is_form {
194 return get_form_field(&req.body, &self.field_name);
195 }
196 None
197 }
198
199 fn cookie_header_value(&self, token: &str) -> String {
200 let mut s = format!(
201 "{}={}; Path=/; SameSite={}",
202 self.cookie_name, token, self.same_site
203 );
204 if self.http_only {
205 s.push_str("; HttpOnly");
206 }
207 if self.secure {
208 s.push_str("; Secure");
209 }
210 s
211 }
212}
213
214impl Middleware for CsrfLayer {
215 fn handle(
216 &self,
217 request: &Request,
218 connection: &ConnectionInfo,
219 next: &dyn Application,
220 ) -> Result<Response, String> {
221 if is_safe_method(&request.method) {
222 let token = self
223 .get_cookie_token(request)
224 .unwrap_or_else(|| self.generate_token());
225
226 // Inject so CsrfToken::from_request can retrieve it inside the handler.
227 let mut req = request.clone();
228 req.headers.push(Header {
229 name: INJECTED_HEADER.to_string().into(),
230 value: token.clone().into(),
231 });
232
233 let mut response = next.execute(&req, connection)?;
234 response.headers.push(Header {
235 name: "Set-Cookie".into(),
236 value: self.cookie_header_value(&token).into(),
237 });
238 Ok(response)
239 } else {
240 let cookie_token = match self.get_cookie_token(request) {
241 Some(t) => t,
242 None => return Ok(AppError::Forbidden.into_response()),
243 };
244 let submitted_token = match self.get_submitted_token(request) {
245 Some(t) => t,
246 None => return Ok(AppError::Forbidden.into_response()),
247 };
248 if !ct_eq(cookie_token.as_bytes(), submitted_token.as_bytes()) {
249 return Ok(AppError::Forbidden.into_response());
250 }
251 next.execute(request, connection)
252 }
253 }
254}
255
256/// The CSRF token for the current request.
257///
258/// Obtain via [`CsrfToken::from_request`] inside a `GET` handler (after
259/// [`CsrfLayer`] has run) and embed the value in your HTML form.
260///
261/// ```rust,no_run
262/// # extern crate rws_server as rust_web_server;
263/// use rust_web_server::csrf::CsrfToken;
264/// use rust_web_server::request::Request;
265///
266/// fn page(req: &Request) -> String {
267/// let token = CsrfToken::from_request(req)
268/// .map(|t| t.value().to_string())
269/// .unwrap_or_default();
270/// format!(r#"<input type="hidden" name="_csrf" value="{token}">"#)
271/// }
272/// ```
273pub struct CsrfToken(String);
274
275impl CsrfToken {
276 /// Return the token string.
277 pub fn value(&self) -> &str {
278 &self.0
279 }
280
281 /// Extract the current CSRF token from a request.
282 ///
283 /// Returns `Some` only when [`CsrfLayer`] is in the middleware stack and
284 /// the request was already processed by it. Returns `None` otherwise.
285 pub fn from_request(req: &Request) -> Option<Self> {
286 for h in &req.headers {
287 if h.name.eq_ignore_ascii_case(INJECTED_HEADER) {
288 return Some(CsrfToken(h.value.clone().into_owned()));
289 }
290 }
291 None
292 }
293}
294
295impl std::fmt::Display for CsrfToken {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 write!(f, "{}", self.0)
298 }
299}
300
301// ── Private helpers ──────────────────────────────────────────────────────────
302
303fn is_safe_method(method: &str) -> bool {
304 matches!(method, "GET" | "HEAD" | "OPTIONS" | "TRACE")
305}
306
307fn get_cookie(req: &Request, name: &str) -> Option<String> {
308 for h in &req.headers {
309 if h.name.eq_ignore_ascii_case("cookie") {
310 for part in h.value.split(';') {
311 let part = part.trim();
312 if let Some(pos) = part.find('=') {
313 let k = part[..pos].trim();
314 if k.eq_ignore_ascii_case(name) {
315 return Some(part[pos + 1..].trim().to_string());
316 }
317 }
318 }
319 }
320 }
321 None
322}
323
324fn get_form_field(body: &[u8], field: &str) -> Option<String> {
325 let s = std::str::from_utf8(body).ok()?;
326 for pair in s.split('&') {
327 let mut parts = pair.splitn(2, '=');
328 let k = parts.next()?.trim();
329 if k == field {
330 return Some(parts.next().unwrap_or("").to_string());
331 }
332 }
333 None
334}
335
336/// Constant-time byte-slice equality to prevent timing attacks.
337fn ct_eq(a: &[u8], b: &[u8]) -> bool {
338 if a.len() != b.len() {
339 return false;
340 }
341 let mut diff = 0u8;
342 for (x, y) in a.iter().zip(b.iter()) {
343 diff |= x ^ y;
344 }
345 diff == 0
346}