diff --git a/assets/views/account/password.html b/assets/views/account/password.html index 784df64..38a8aa5 100644 --- a/assets/views/account/password.html +++ b/assets/views/account/password.html @@ -22,6 +22,7 @@
+ {{ ui::csrf_field() }}
{{ ui::input(name="current_password", id="current_password", type="password", required=true, autocomplete="current-password") }} diff --git a/assets/views/account/profile.html b/assets/views/account/profile.html index 61c58fd..244fcc9 100644 --- a/assets/views/account/profile.html +++ b/assets/views/account/profile.html @@ -82,6 +82,7 @@ + {{ ui::csrf_field() }}
{{ t(key="account-type", lang=lang | default(value='sk')) }} diff --git a/assets/views/account/security.html b/assets/views/account/security.html index f9bf027..8094117 100644 --- a/assets/views/account/security.html +++ b/assets/views/account/security.html @@ -39,6 +39,7 @@ {{ secret }}
+ {{ ui::csrf_field() }} {{ ui::input(name="code", id="code", type="text", required=true, autocomplete="one-time-code", attrs='inputmode="numeric" pattern="[0-9]*" maxlength="6" autofocus') }} {{ ui::button(label=t(key="security-2fa-confirm", lang=lang | default(value='sk')), type="submit", extra="w-full") }} @@ -53,6 +54,7 @@ + {{ ui::csrf_field() }}

{{ t(key="security-2fa-regenerate", lang=lang | default(value='sk')) }}

{{ ui::input(name="current_password", id="regen_pw", type="password", required=true, autocomplete="current-password") }} @@ -60,6 +62,7 @@
+ {{ ui::csrf_field() }}

{{ t(key="security-2fa-disable", lang=lang | default(value='sk')) }}

{{ t(key="security-2fa-disable-hint", lang=lang | default(value='sk')) }}

@@ -70,6 +73,7 @@ {% else %} {# --- Disabled: offer to enable --- #} + {{ ui::csrf_field() }}
{{ ui::badge(label=t(key="security-2fa-off", lang=lang | default(value='sk')), variant="neutral") }}
diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index 53f85a0..b6fdf97 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -43,38 +43,9 @@ {% block head %}{% endblock head %} - - @@ -126,6 +97,7 @@ {{ t(key="admin-exit", lang=lang | default(value='sk')) }} + {{ ui::csrf_field() }} diff --git a/assets/views/admin/catalog/categories.html b/assets/views/admin/catalog/categories.html index 7402e24..ea739c2 100644 --- a/assets/views/admin/catalog/categories.html +++ b/assets/views/admin/catalog/categories.html @@ -46,6 +46,7 @@ {{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/categories/" ~ row.category.id ~ "/edit", size="px-3 py-1.5 text-xs") }} + {{ ui::csrf_field() }} {{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
diff --git a/assets/views/admin/catalog/category_form.html b/assets/views/admin/catalog/category_form.html index abd494f..abcfe45 100644 --- a/assets/views/admin/catalog/category_form.html +++ b/assets/views/admin/catalog/category_form.html @@ -15,6 +15,7 @@
+ {{ ui::csrf_field() }} {% if category %} {% set v_name = category.name %}{% set v_slug = category.slug %}{% set v_pos = category.position %}{% set v_desc = category.description | default(value="") %}{% set v_pub = category.published %} diff --git a/assets/views/admin/catalog/product_form.html b/assets/views/admin/catalog/product_form.html index a6936ad..e4da665 100644 --- a/assets/views/admin/catalog/product_form.html +++ b/assets/views/admin/catalog/product_form.html @@ -15,6 +15,7 @@ + {{ ui::csrf_field() }} {% if product %} {% set v_name = product.name %}{% set v_price = product.price %}{% set v_currency = product.currency %}{% set v_stock = product.stock %}{% set v_sku = product.sku | default(value="") %}{% set v_slug = product.slug %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %} diff --git a/assets/views/admin/catalog/products.html b/assets/views/admin/catalog/products.html index 9c20de7..f8d70c2 100644 --- a/assets/views/admin/catalog/products.html +++ b/assets/views/admin/catalog/products.html @@ -56,6 +56,7 @@ {{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }} + {{ ui::csrf_field() }} {{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
diff --git a/assets/views/admin/orders/show.html b/assets/views/admin/orders/show.html index aa7bb89..ef3fd25 100644 --- a/assets/views/admin/orders/show.html +++ b/assets/views/admin/orders/show.html @@ -110,6 +110,7 @@

{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}

+ {{ ui::csrf_field() }} {% set carrier_up = carrier | upper %} {% set ship_label = t(key="order-send-to-carrier", lang=lang | default(value='sk')) ~ " " ~ carrier_up %} {{ ui::button(label=ship_label, type="submit", extra="w-full") }} @@ -118,6 +119,7 @@ + {{ ui::csrf_field() }}
diff --git a/assets/views/base.html b/assets/views/base.html index f998068..4db41b4 100644 --- a/assets/views/base.html +++ b/assets/views/base.html @@ -67,38 +67,9 @@ required by the Penguin UI keyboard-accessible dropdowns. --> - - @@ -121,6 +92,7 @@
  • {{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}
  • + {{ ui::csrf_field() }}
  • @@ -193,6 +165,7 @@
  • {{ t(key="admin-title", lang=lang | default(value='sk')) }}
  • + {{ ui::csrf_field() }}
  • @@ -200,6 +173,7 @@
  • {{ t(key="nav-profile", lang=lang | default(value='sk')) }}
  • + {{ ui::csrf_field() }}
  • @@ -229,6 +203,7 @@
  • {{ t(key="security-title", lang=lang | default(value='sk')) }}
  • + {{ ui::csrf_field() }}
    diff --git a/assets/views/macros/ui.html b/assets/views/macros/ui.html index 6dffda6..8ff8e71 100644 --- a/assets/views/macros/ui.html +++ b/assets/views/macros/ui.html @@ -29,6 +29,13 @@ outline : outline-primary | outline-secondary | outline-alternate | outline-danger ghost : ghost-primary | ghost-secondary | ghost-danger #} +{# CSRF hidden field for native (non-htmx)
    submits. htmx + requests instead inherit the X-CSRF-Token header from . + `csrf_token()` is a global Tera function bound per-request by shared::csrf. #} +{% macro csrf_field() -%} + +{%- endmacro %} + {% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm") -%} {%- if variant == "secondary" -%}{% set cls = "border border-secondary bg-secondary text-on-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark" -%} {%- elif variant == "danger" -%}{% set cls = "border border-danger bg-danger text-on-danger focus-visible:outline-danger dark:bg-danger dark:border-danger dark:text-on-danger dark:focus-visible:outline-danger" -%} diff --git a/assets/views/partials/profile_menu.html b/assets/views/partials/profile_menu.html index ac6a8f6..85662f6 100644 --- a/assets/views/partials/profile_menu.html +++ b/assets/views/partials/profile_menu.html @@ -68,7 +68,8 @@
    - diff --git a/assets/views/partials/settings_dropdown.html b/assets/views/partials/settings_dropdown.html index 6a5da1b..e3c3561 100644 --- a/assets/views/partials/settings_dropdown.html +++ b/assets/views/partials/settings_dropdown.html @@ -20,6 +20,7 @@ class="absolute right-0 mt-2 flex w-56 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt py-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt" role="menu">
    +

    {{ t(key="settings-language", lang=lang | default(value='sk')) }}

    diff --git a/assets/views/shop/_card.html b/assets/views/shop/_card.html index d85aa54..6b6fa18 100644 --- a/assets/views/shop/_card.html +++ b/assets/views/shop/_card.html @@ -26,6 +26,7 @@

    {{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}

    + {{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='') }} diff --git a/assets/views/shop/_cart_body.html b/assets/views/shop/_cart_body.html index 764f2b3..d04dd01 100644 --- a/assets/views/shop/_cart_body.html +++ b/assets/views/shop/_cart_body.html @@ -27,6 +27,7 @@ reverting to the previous quantity if the customer cancels. #} + {{ ui::csrf_field() }} + {{ ui::csrf_field() }} {{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
    diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout.html index 194b7dd..a1b650c 100644 --- a/assets/views/shop/checkout.html +++ b/assets/views/shop/checkout.html @@ -21,7 +21,8 @@ packetaKey: '{{ packeta_api_key }}', fmt(c) { return (c / 100).toFixed(2) }, pickPoint() { - Packeta.Widget.pick(this.packetaKey, (point) => { + Packeta.Widget.pick(this.packetaKey, (point) => + {{ ui::csrf_field() }} { if (point) { this.pointId = String(point.id); this.pointName = point.formatedValue || point.name } }) }, diff --git a/assets/views/shop/show.html b/assets/views/shop/show.html index bc30d5e..010e92b 100644 --- a/assets/views/shop/show.html +++ b/assets/views/shop/show.html @@ -63,6 +63,7 @@ {% if product.stock > 0 %}
    + {{ ui::csrf_field() }}
    diff --git a/src/initializers/view_engine.rs b/src/initializers/view_engine.rs index 7571cf9..5be7e90 100644 --- a/src/initializers/view_engine.rs +++ b/src/initializers/view_engine.rs @@ -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 `` + // 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| { + 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| { + Ok(serde_json::Value::String( + crate::shared::csrf::current_token().unwrap_or_default(), + )) + }); + Ok(()) + })? }; Ok(router.layer(Extension(ViewEngine::from(tera_engine)))) diff --git a/src/shared/csrf.rs b/src/shared/csrf.rs index 1fab61b..6456792 100644 --- a/src/shared/csrf.rs +++ b/src/shared/csrf.rs @@ -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 `` 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 { + REQUEST_TOKEN.try_with(String::clone).ok() +} + type HmacSha256 = Hmac; 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)]