csrf implemented
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2650,11 +2650,15 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"fluent-templates",
|
"fluent-templates",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"hmac",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"insta",
|
"insta",
|
||||||
"loco-oauth2",
|
"loco-oauth2",
|
||||||
"loco-rs",
|
"loco-rs",
|
||||||
"migration",
|
"migration",
|
||||||
|
"multer",
|
||||||
"passwords",
|
"passwords",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -2663,6 +2667,8 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"totp-rs",
|
"totp-rs",
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ passwords = "3.1.16"
|
|||||||
tower-sessions = "0.14"
|
tower-sessions = "0.14"
|
||||||
# TOTP (Google Authenticator) for optional two-factor auth
|
# TOTP (Google Authenticator) for optional two-factor auth
|
||||||
totp-rs = { version = "5", features = ["qr", "gen_secret"] }
|
totp-rs = { version = "5", features = ["qr", "gen_secret"] }
|
||||||
|
# CSRF: HMAC-signed double-submit token + body inspection for the `_csrf` field
|
||||||
|
hmac = { version = "0.12" }
|
||||||
|
sha2 = { version = "0.10" }
|
||||||
|
subtle = { version = "2.6" }
|
||||||
|
form_urlencoded = { version = "1" }
|
||||||
|
multer = { version = "3" }
|
||||||
|
futures-util = { version = "0.3" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "kompress-eshop-cli"
|
name = "kompress-eshop-cli"
|
||||||
|
|||||||
@@ -43,6 +43,36 @@
|
|||||||
{% block head %}{% endblock head %}
|
{% block head %}{% endblock head %}
|
||||||
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
||||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
|
<!-- CSRF: echo the signed `csrf_token` cookie back on every unsafe request.
|
||||||
|
htmx requests get it as an X-CSRF-Token header; native <form> submits
|
||||||
|
can't set a header, so a hidden _csrf field is injected instead.
|
||||||
|
Server side: shared::csrf::protect. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function csrfToken() {
|
||||||
|
var m = document.cookie.split('; ').find(function (c) { return c.indexOf('csrf_token=') === 0; });
|
||||||
|
return m ? decodeURIComponent(m.split('=').slice(1).join('=')) : '';
|
||||||
|
}
|
||||||
|
document.addEventListener('htmx:configRequest', function (e) {
|
||||||
|
var t = csrfToken();
|
||||||
|
if (t) e.detail.headers['X-CSRF-Token'] = t;
|
||||||
|
});
|
||||||
|
document.addEventListener('submit', function (e) {
|
||||||
|
var form = e.target;
|
||||||
|
if (!form || (form.method || '').toLowerCase() !== 'post') return;
|
||||||
|
var t = csrfToken();
|
||||||
|
if (!t) return;
|
||||||
|
var input = form.querySelector('input[name="_csrf"]');
|
||||||
|
if (!input) {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = '_csrf';
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
input.value = t;
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
x-data="{ showSidebar: false }"
|
x-data="{ showSidebar: false }"
|
||||||
|
|||||||
@@ -67,6 +67,36 @@
|
|||||||
required by the Penguin UI keyboard-accessible dropdowns. -->
|
required by the Penguin UI keyboard-accessible dropdowns. -->
|
||||||
<script defer src="/static/vendor/alpine/alpine-focus-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpine-focus-3.14.9.min.js"></script>
|
||||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
|
<!-- CSRF: echo the signed `csrf_token` cookie back on every unsafe request.
|
||||||
|
htmx requests get it as an X-CSRF-Token header; native <form> submits
|
||||||
|
(hx-boost="false") can't set a header, so a hidden _csrf field is
|
||||||
|
injected instead. Server side: shared::csrf::protect. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function csrfToken() {
|
||||||
|
var m = document.cookie.split('; ').find(function (c) { return c.indexOf('csrf_token=') === 0; });
|
||||||
|
return m ? decodeURIComponent(m.split('=').slice(1).join('=')) : '';
|
||||||
|
}
|
||||||
|
document.addEventListener('htmx:configRequest', function (e) {
|
||||||
|
var t = csrfToken();
|
||||||
|
if (t) e.detail.headers['X-CSRF-Token'] = t;
|
||||||
|
});
|
||||||
|
document.addEventListener('submit', function (e) {
|
||||||
|
var form = e.target;
|
||||||
|
if (!form || (form.method || '').toLowerCase() !== 'post') return;
|
||||||
|
var t = csrfToken();
|
||||||
|
if (!t) return;
|
||||||
|
var input = form.querySelector('input[name="_csrf"]');
|
||||||
|
if (!input) {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = '_csrf';
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
input.value = t;
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-boost="true"
|
<body hx-boost="true"
|
||||||
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ impl Hooks for App {
|
|||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
crate::shared::rbac::inject_subject,
|
crate::shared::rbac::inject_subject,
|
||||||
|
))
|
||||||
|
// CSRF runs outermost so it validates the double-submit token before
|
||||||
|
// any handler sees the request and stamps the cookie on safe ones.
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
ctx.clone(),
|
||||||
|
crate::shared::csrf::protect,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
279
src/shared/csrf.rs
Normal file
279
src/shared/csrf.rs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
//! Stateless CSRF protection (signed double-submit cookie).
|
||||||
|
//!
|
||||||
|
//! Authentication is cookie-based (the `auth_token` JWT cookie, see
|
||||||
|
//! [`crate::shared::guard`]), so any state-changing request the browser can be
|
||||||
|
//! tricked into making carries the victim's credentials. `SameSite=Lax` on the
|
||||||
|
//! auth cookie already blocks the cross-site *form* POST case; this layer is the
|
||||||
|
//! defense-in-depth on top of it.
|
||||||
|
//!
|
||||||
|
//! ## How it works
|
||||||
|
//! On every safe request we ensure the browser holds a `csrf_token` cookie whose
|
||||||
|
//! value is `<random>.<hmac>` — the HMAC is keyed by the app's JWT secret, so the
|
||||||
|
//! server can recognise its own tokens without storing any per-session state
|
||||||
|
//! (no session table, survives restarts and multiple instances). On every unsafe
|
||||||
|
//! request ([`protect`]) the same token must be echoed back, either as the
|
||||||
|
//! `X-CSRF-Token` header (htmx requests) or a `_csrf` form field (native
|
||||||
|
//! `<form>` submits, which cannot set a custom header). Because a cross-origin
|
||||||
|
//! attacker can neither read the cookie nor forge a valid signature, they cannot
|
||||||
|
//! produce a matching echo.
|
||||||
|
//!
|
||||||
|
//! The browser side lives in the two base templates (`base.html`,
|
||||||
|
//! `admin/base.html`): an `htmx:configRequest` hook adds the header and a
|
||||||
|
//! `submit` hook injects the hidden `_csrf` field.
|
||||||
|
//!
|
||||||
|
//! `/api/*` is exempt: that subtree is the token-authenticated JSON API and the
|
||||||
|
//! OAuth2 callback, neither of which is driven by the browser session cookie.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::{to_bytes, Body},
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{header, Method, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use time::Duration as TimeDuration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Cookie that holds the signed token. Deliberately *not* `HttpOnly`: the page
|
||||||
|
/// JS has to read it to echo it back in the header / hidden field.
|
||||||
|
pub const CSRF_COOKIE: &str = "csrf_token";
|
||||||
|
/// Header carrying the echoed token on htmx (and any scripted) requests.
|
||||||
|
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||||
|
/// Hidden form field carrying the echoed token on native `<form>` submits.
|
||||||
|
pub const CSRF_FIELD: &str = "_csrf";
|
||||||
|
|
||||||
|
/// Cookie lifetime. Long enough to outlast a normal browsing session; the token
|
||||||
|
/// only needs to be stable, not short-lived (it is not a credential on its own).
|
||||||
|
const COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 24 * 14;
|
||||||
|
/// Upper bound on a body we will buffer to find the `_csrf` field. Covers the
|
||||||
|
/// largest native multipart submit (a 10 MiB image upload plus the other
|
||||||
|
/// fields); anything bigger is rejected rather than buffered.
|
||||||
|
const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMAC-SHA256 of the random part, keyed by the app secret.
|
||||||
|
fn sign(secret: &str, random: &str) -> String {
|
||||||
|
let mut mac =
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length");
|
||||||
|
mac.update(random.as_bytes());
|
||||||
|
to_hex(&mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a fresh `<random>.<hmac>` token.
|
||||||
|
pub fn make_token(secret: &str) -> String {
|
||||||
|
let random = Uuid::new_v4().simple().to_string();
|
||||||
|
let sig = sign(secret, &random);
|
||||||
|
format!("{random}.{sig}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `token` is well-formed and its signature is the one this server
|
||||||
|
/// would produce — i.e. it is one of *our* tokens, not an attacker-injected one.
|
||||||
|
fn signature_valid(secret: &str, token: &str) -> bool {
|
||||||
|
let Some((random, sig)) = token.split_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let expected = sign(secret, random);
|
||||||
|
expected.as_bytes().ct_eq(sig.as_bytes()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time equality of two full tokens (the double-submit comparison).
|
||||||
|
fn tokens_match(a: &str, b: &str) -> bool {
|
||||||
|
a.as_bytes().ct_eq(b.as_bytes()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_cookie(token: String) -> Cookie<'static> {
|
||||||
|
Cookie::build((CSRF_COOKIE, token))
|
||||||
|
.path("/")
|
||||||
|
.http_only(false)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(COOKIE_MAX_AGE_SECS))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forbidden(reason: &str) -> Response {
|
||||||
|
tracing::debug!(reason, "CSRF check rejected request");
|
||||||
|
(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));
|
||||||
|
if let Ok(value) = cookie.encoded().to_string().parse() {
|
||||||
|
res.headers_mut().append(header::SET_COOKIE, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the `_csrf` value out of an `application/x-www-form-urlencoded` body.
|
||||||
|
fn field_from_urlencoded(bytes: &[u8]) -> Option<String> {
|
||||||
|
form_urlencoded::parse(bytes)
|
||||||
|
.find(|(k, _)| k == CSRF_FIELD)
|
||||||
|
.map(|(_, v)| v.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the `_csrf` value out of a `multipart/form-data` body. Parses a *copy*
|
||||||
|
/// of the buffered bytes purely to read the field; the original bytes are still
|
||||||
|
/// forwarded to the handler unchanged.
|
||||||
|
async fn field_from_multipart(content_type: &str, bytes: Bytes) -> Option<String> {
|
||||||
|
let boundary = multer::parse_boundary(content_type).ok()?;
|
||||||
|
let stream = futures_util::stream::once(async move { Ok::<_, std::io::Error>(bytes) });
|
||||||
|
let mut multipart = multer::Multipart::new(stream, boundary);
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
if field.name() == Some(CSRF_FIELD) {
|
||||||
|
return field.text().await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CSRF enforcement middleware. Safe methods get a token cookie (minted if
|
||||||
|
/// missing); unsafe methods must echo a valid, matching token. See the module
|
||||||
|
/// docs for the full scheme.
|
||||||
|
pub async fn protect(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
jar: CookieJar,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// The token is keyed by the JWT secret. If no secret is configured we cannot
|
||||||
|
// validate, so fail open rather than 403 the whole site — the same secret is
|
||||||
|
// already required for auth to work at all.
|
||||||
|
let Ok(jwt) = ctx.config.get_jwt_config() else {
|
||||||
|
return next.run(req).await;
|
||||||
|
};
|
||||||
|
let secret = jwt.secret.clone();
|
||||||
|
|
||||||
|
// The token the browser currently holds, accepted only if untampered.
|
||||||
|
let cookie_token = jar
|
||||||
|
.get(CSRF_COOKIE)
|
||||||
|
.map(|c| c.value().to_string())
|
||||||
|
.filter(|t| signature_valid(&secret, t));
|
||||||
|
|
||||||
|
let is_safe = matches!(
|
||||||
|
*req.method(),
|
||||||
|
Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
|
||||||
|
);
|
||||||
|
// Token-auth JSON API + OAuth2 callback: not browser-cookie-driven.
|
||||||
|
let exempt = req.uri().path().starts_with("/api/");
|
||||||
|
|
||||||
|
if is_safe || exempt {
|
||||||
|
let mut res = next.run(req).await;
|
||||||
|
if cookie_token.is_none() {
|
||||||
|
set_fresh_cookie(&mut res, &secret);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- unsafe, non-exempt: require a valid double-submit ----
|
||||||
|
let Some(expected) = cookie_token else {
|
||||||
|
return forbidden("missing or tampered CSRF cookie");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
return forbidden("CSRF header did not match cookie");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native <form> submit: the token is a `_csrf` body field. Buffer the body,
|
||||||
|
// read the field from a copy, then forward the original bytes untouched.
|
||||||
|
let content_type = req
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (parts, body) = req.into_parts();
|
||||||
|
let Ok(bytes) = to_bytes(body, MAX_BODY_BYTES).await else {
|
||||||
|
return forbidden("request body too large to validate CSRF");
|
||||||
|
};
|
||||||
|
|
||||||
|
let submitted = if content_type.starts_with("application/x-www-form-urlencoded") {
|
||||||
|
field_from_urlencoded(&bytes)
|
||||||
|
} else if content_type.starts_with("multipart/form-data") {
|
||||||
|
field_from_multipart(&content_type, bytes.clone()).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid = submitted
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|t| tokens_match(t, &expected));
|
||||||
|
if !valid {
|
||||||
|
return forbidden("missing or non-matching CSRF form field");
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(Request::from_parts(parts, Body::from(bytes))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SECRET: &str = "test-secret";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_token_validates() {
|
||||||
|
let token = make_token(SECRET);
|
||||||
|
assert!(signature_valid(SECRET, &token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_or_foreign_token_rejected() {
|
||||||
|
let token = make_token(SECRET);
|
||||||
|
// Flip the random part but keep the old signature.
|
||||||
|
let (_, sig) = token.split_once('.').unwrap();
|
||||||
|
let forged = format!("{}.{sig}", Uuid::new_v4().simple());
|
||||||
|
assert!(!signature_valid(SECRET, &forged));
|
||||||
|
// A token minted under a different secret is not ours.
|
||||||
|
assert!(!signature_valid(SECRET, &make_token("other-secret")));
|
||||||
|
// Malformed input never validates.
|
||||||
|
assert!(!signature_valid(SECRET, "no-dot-here"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_submit_compare() {
|
||||||
|
let token = make_token(SECRET);
|
||||||
|
assert!(tokens_match(&token, &token.clone()));
|
||||||
|
assert!(!tokens_match(&token, &make_token(SECRET)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_field_from_urlencoded() {
|
||||||
|
let body = b"email=a%40b.com&_csrf=abc.def&password=x";
|
||||||
|
assert_eq!(field_from_urlencoded(body), Some("abc.def".to_string()));
|
||||||
|
assert_eq!(field_from_urlencoded(b"email=x"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn extract_field_from_multipart() {
|
||||||
|
let boundary = "X-BOUNDARY";
|
||||||
|
let content_type = format!("multipart/form-data; boundary={boundary}");
|
||||||
|
let body = format!(
|
||||||
|
"--{b}\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nWidget\r\n\
|
||||||
|
--{b}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\nabc.def\r\n\
|
||||||
|
--{b}--\r\n",
|
||||||
|
b = boundary
|
||||||
|
);
|
||||||
|
let got = field_from_multipart(&content_type, Bytes::from(body)).await;
|
||||||
|
assert_eq!(got, Some("abc.def".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Cross-cutting helpers used across feature slices.
|
//! Cross-cutting helpers used across feature slices.
|
||||||
|
|
||||||
|
pub mod csrf;
|
||||||
pub mod guard;
|
pub mod guard;
|
||||||
pub mod money;
|
pub mod money;
|
||||||
pub mod rbac;
|
pub mod rbac;
|
||||||
|
|||||||
Reference in New Issue
Block a user