tokens and more
This commit is contained in:
+118
-1
@@ -7,10 +7,26 @@
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, Uri, header};
|
||||
use axum::{
|
||||
Json,
|
||||
http::{HeaderMap, HeaderValue, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "__Host-rustpad_session";
|
||||
pub const CSRF_COOKIE: &str = "__Host-rustpad_csrf";
|
||||
pub const CSRF_HEADER: &str = "x-rustpad-csrf";
|
||||
|
||||
const CSRF_TOKEN_BYTES: usize = 32;
|
||||
const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CsrfResponse {
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub fn session_token(headers: &HeaderMap) -> Option<&str> {
|
||||
session_cookie_token(headers)
|
||||
@@ -42,6 +58,44 @@ pub fn clear_session_cookie() -> HeaderValue {
|
||||
clear_cookie(SESSION_COOKIE)
|
||||
}
|
||||
|
||||
pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
|
||||
let token = csrf_cookie_token(&headers)
|
||||
.filter(|value| valid_csrf_token(value))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(random_csrf_token);
|
||||
let mut response = Json(CsrfResponse {
|
||||
token: token.clone(),
|
||||
})
|
||||
.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::SET_COOKIE, csrf_cookie(&token));
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, max-age=0"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn csrf_request_is_valid(headers: &HeaderMap) -> bool {
|
||||
let Some(cookie) = csrf_cookie_token(headers).filter(|value| valid_csrf_token(value)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(provided) = headers
|
||||
.get(CSRF_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| valid_csrf_token(value))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
constant_time_eq(cookie.as_bytes(), provided.as_bytes())
|
||||
}
|
||||
|
||||
pub fn csrf_cookie_token(headers: &HeaderMap) -> Option<&str> {
|
||||
cookie_value(headers, CSRF_COOKIE)
|
||||
}
|
||||
|
||||
pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue {
|
||||
secure_cookie(
|
||||
&resource_cookie_name(kind, slug),
|
||||
@@ -127,6 +181,37 @@ fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
|
||||
.expect("valid secure cookie")
|
||||
}
|
||||
|
||||
fn csrf_cookie(token: &str) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!(
|
||||
"{CSRF_COOKIE}={token}; Path=/; Max-Age={}; Secure; SameSite=Strict",
|
||||
CSRF_TTL_SECONDS
|
||||
))
|
||||
.expect("valid csrf cookie")
|
||||
}
|
||||
|
||||
fn random_csrf_token() -> String {
|
||||
let mut bytes = [0_u8; CSRF_TOKEN_BYTES];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn valid_csrf_token(value: &str) -> bool {
|
||||
value.len() == CSRF_TOKEN_BYTES * 2 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.fold(0_u8, |difference, (left, right)| {
|
||||
difference | (*left ^ *right)
|
||||
})
|
||||
== 0
|
||||
}
|
||||
|
||||
fn clear_cookie(name: &str) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!(
|
||||
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
||||
@@ -232,4 +317,36 @@ mod tests {
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_requires_matching_cookie_and_header() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_str(&token).unwrap(),
|
||||
);
|
||||
assert!(csrf_request_is_valid(&headers));
|
||||
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_static(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
),
|
||||
);
|
||||
assert!(!csrf_request_is_valid(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_cookie_is_strict_and_script_readable() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let value = csrf_cookie(&token).to_str().unwrap();
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Strict"));
|
||||
assert!(!value.contains("HttpOnly"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user