custom JS removed in favor of proper CSRF implementation

This commit is contained in:
Priec
2026-06-21 18:22:21 +02:00
parent 86888b3877
commit db6b609937
25 changed files with 94 additions and 72 deletions

View File

@@ -6,6 +6,7 @@ use loco_rs::{
controller::views::{engines, ViewEngine},
Error, Result,
};
use std::collections::HashMap;
use tracing::info;
const I18N_DIR: &str = "assets/i18n";
@@ -35,10 +36,27 @@ impl Initializer for ViewEngineInitializer {
engines::TeraView::build()?.post_process(move |tera| {
tera.register_function("t", FluentLoader::new(arc.clone()));
// `csrf_token()`: the in-flight request's CSRF token (bound by
// `shared::csrf::protect`), rendered into `<body hx-headers>`
// and `ui::csrf_field()`. Inlined so its `tera::Error` return is
// inferred from `register_function` — we never name a `tera`
// type, keeping it off our direct deps and pinned to loco's.
tera.register_function("csrf_token", |_args: &HashMap<String, serde_json::Value>| {
Ok(serde_json::Value::String(
crate::shared::csrf::current_token().unwrap_or_default(),
))
});
Ok(())
})?
} else {
engines::TeraView::build()?
engines::TeraView::build()?.post_process(|tera| {
tera.register_function("csrf_token", |_args: &HashMap<String, serde_json::Value>| {
Ok(serde_json::Value::String(
crate::shared::csrf::current_token().unwrap_or_default(),
))
});
Ok(())
})?
};
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))

View File

@@ -56,6 +56,22 @@ const COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 24 * 14;
/// fields); anything bigger is rejected rather than buffered.
const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
tokio::task_local! {
/// CSRF token bound to the in-flight request. [`protect`] sets it so the
/// `csrf_token()` Tera function can render it into pages (the `hx-headers`
/// on `<body>` and the `ui::csrf_field()` hidden input) without every
/// controller having to thread it through the view context.
static REQUEST_TOKEN: String;
}
/// The CSRF token for the current request task, if one is bound. Returns `None`
/// outside a request (e.g. a mailer rendering a template). Used by the
/// `csrf_token()` Tera function registered in the view engine.
#[must_use]
pub fn current_token() -> Option<String> {
REQUEST_TOKEN.try_with(String::clone).ok()
}
type HmacSha256 = Hmac<Sha256>;
fn to_hex(bytes: &[u8]) -> String {
@@ -110,9 +126,9 @@ fn forbidden(reason: &str) -> Response {
(StatusCode::FORBIDDEN, "CSRF validation failed").into_response()
}
/// Attach a freshly minted token cookie to an outgoing response.
fn set_fresh_cookie(res: &mut Response, secret: &str) {
let cookie = issue_cookie(make_token(secret));
/// Attach the given token to an outgoing response as the `csrf_token` cookie.
fn attach_cookie(res: &mut Response, token: &str) {
let cookie = issue_cookie(token.to_string());
if let Ok(value) = cookie.encoded().to_string().parse() {
res.headers_mut().append(header::SET_COOKIE, value);
}
@@ -171,9 +187,12 @@ pub async fn protect(
let exempt = req.uri().path().starts_with("/api/");
if is_safe || exempt {
let mut res = next.run(req).await;
// Bind a token for the page to render even on the very first visit, and
// persist that same token in the cookie so the later submit matches.
let token = cookie_token.clone().unwrap_or_else(|| make_token(&secret));
let mut res = REQUEST_TOKEN.scope(token.clone(), next.run(req)).await;
if cookie_token.is_none() {
set_fresh_cookie(&mut res, &secret);
attach_cookie(&mut res, &token);
}
return res;
}
@@ -185,9 +204,14 @@ pub async fn protect(
// htmx and other scripted requests send the token as a header; prefer it so
// we never have to touch the body.
if let Some(header_token) = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) {
if tokens_match(header_token, &expected) {
return next.run(req).await;
let header_token = req
.headers()
.get(CSRF_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let Some(header_token) = header_token {
if tokens_match(&header_token, &expected) {
return REQUEST_TOKEN.scope(expected, next.run(req)).await;
}
return forbidden("CSRF header did not match cookie");
}
@@ -221,7 +245,8 @@ pub async fn protect(
return forbidden("missing or non-matching CSRF form field");
}
next.run(Request::from_parts(parts, Body::from(bytes))).await
let req = Request::from_parts(parts, Body::from(bytes));
REQUEST_TOKEN.scope(expected, next.run(req)).await
}
#[cfg(test)]