custom JS removed in favor of proper CSRF implementation
This commit is contained in:
@@ -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))))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user