From a7329f5535c9a2c015540e4c41ff27aa279a11c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 5 Aug 2026 23:52:41 +0200 Subject: [PATCH] fixes and functions --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/auth/mod.rs | 72 ++++++++++++++--- src/queries/mod.rs | 3 + src/queries/mysql.rs | 3 + src/queries/postgres.rs | 3 + src/queries/sqlite.rs | 3 + src/security.rs | 37 ++++++++- src/tests/auth.rs | 170 ++++++++++++++++++++++++++++++++++++++++ src/tests/queries.rs | 1 + src/tests/security.rs | 36 +++++++++ static/js/auth-ui.js | 7 ++ static/js/session.js | 6 ++ 13 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 src/tests/auth.rs diff --git a/Cargo.lock b/Cargo.lock index aa54771..ac73f4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.43" +version = "0.2.44" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 83e00cb..a533243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.43" +version = "0.2.44" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/auth/mod.rs b/src/auth/mod.rs index f0b3882..6eea1f1 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -2218,23 +2218,67 @@ pub async fn logout( State(state): State, headers: HeaderMap, ) -> Result { - if let Some(token) = crate::security::session_token(&headers) { - let result = sqlx::query(queries::get( - state.db.kind(), - queries::AUTH_DELETE_SESSION_BY_TOKEN, - )) - .bind(token) - .execute(state.db.pool()) - .await - .map_err(AuthError::database)?; - info!(rows_affected = result.rows_affected(), "logout processed"); + let session_token = crate::security::session_token(&headers).map(str::to_owned); + let access_cookies = crate::security::resource_access_cookies(&headers); + let has_access_tokens = access_cookies.iter().any(|(_, token)| token.is_some()); + let mut revoked_access_tokens = 0_u64; + + if session_token.is_some() || has_access_tokens { + let mut tx = state + .db + .pool() + .begin() + .await + .map_err(AuthError::database)?; + + if let Some(token) = session_token.as_deref() { + let result = sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_SESSION_BY_TOKEN, + )) + .bind(token) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + info!(rows_affected = result.rows_affected(), "logout processed"); + } else { + debug!("logout requested without an active session"); + } + + for (_, token) in &access_cookies { + let Some(token) = token.as_deref() else { + continue; + }; + let result = sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, + )) + .bind(hash_token(token)) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + revoked_access_tokens += result.rows_affected(); + } + + tx.commit().await.map_err(AuthError::database)?; } else { - debug!("logout requested without an active session"); + debug!("logout requested without an active session or resource access cookies"); } + + debug!( + revoked_access_tokens, + cleared_access_cookies = access_cookies.len(), + "password-derived resource access cleared during logout" + ); let mut response = Json(serde_json::json!({"ok": true})).into_response(); response .headers_mut() - .insert(header::SET_COOKIE, crate::security::clear_session_cookie()); + .append(header::SET_COOKIE, crate::security::clear_session_cookie()); + for (name, _) in access_cookies { + if let Some(cookie) = crate::security::clear_resource_access_cookie(&name) { + response.headers_mut().append(header::SET_COOKIE, cookie); + } + } Ok(response) } @@ -3009,3 +3053,7 @@ impl axum::response::IntoResponse for AuthError { .into_response() } } + +#[cfg(test)] +#[path = "../tests/auth.rs"] +mod logout_tests; diff --git a/src/queries/mod.rs b/src/queries/mod.rs index c7346ca..8be0878 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -88,6 +88,7 @@ pub enum Query { USER_SET_WORKSPACE_PRIVACY, USER_SET_PAD_PRIVACY, RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, + RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, RESOURCE_ACCESS_TOKENS_INSERT, RESOURCE_ACCESS_TOKENS_VALID_COUNT, RESOURCE_PERMISSION_DELETE_USER, @@ -253,6 +254,8 @@ pub const USER_SET_WORKSPACE_PRIVACY: Query = Query::USER_SET_WORKSPACE_PRIVACY; pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY; pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query = Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE; +pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH: Query = + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH; pub const RESOURCE_ACCESS_TOKENS_INSERT: Query = Query::RESOURCE_ACCESS_TOKENS_INSERT; pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT; pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 00ad9d8..fc800b2 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -200,6 +200,9 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# } + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => { + r#"DELETE FROM resource_access_tokens WHERE token_hash = ?"# + } Query::RESOURCE_ACCESS_TOKENS_INSERT => { r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"# } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index f84d012..618c2e3 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -202,6 +202,9 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"# } + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => { + r#"DELETE FROM resource_access_tokens WHERE token_hash = $1"# + } Query::RESOURCE_ACCESS_TOKENS_INSERT => { r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"# } diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index f47c6a3..131c89c 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -200,6 +200,9 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# } + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => { + r#"DELETE FROM resource_access_tokens WHERE token_hash = ?"# + } Query::RESOURCE_ACCESS_TOKENS_INSERT => { r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"# } diff --git a/src/security.rs b/src/security.rs index c7d28cc..cbe977b 100644 --- a/src/security.rs +++ b/src/security.rs @@ -22,6 +22,9 @@ pub const CSRF_HEADER: &str = "x-rustpad-csrf"; const CSRF_TOKEN_BYTES: usize = 32; const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60; +const RESOURCE_ACCESS_COOKIE_PREFIX: &str = "__Host-rustpad_access_"; +const RESOURCE_ACCESS_COOKIE_SUFFIX_LENGTH: usize = 24; +const RESOURCE_ACCESS_TOKEN_LENGTH: usize = 64; #[derive(Serialize)] pub struct CsrfResponse { @@ -50,6 +53,26 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt cookie_value(headers, &name) } +pub(crate) fn resource_access_cookies(headers: &HeaderMap) -> Vec<(String, Option)> { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|cookies| cookies.split(';')) + .filter_map(|part| { + let (name, value) = part.trim().split_once('=')?; + if !valid_resource_access_cookie_name(name) { + return None; + } + let value = value.trim(); + let token = (value.len() == RESOURCE_ACCESS_TOKEN_LENGTH + && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| value.to_owned()); + Some((name.to_owned(), token)) + }) + .collect() +} + pub fn share_session_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Option<&'a str> { let name = share_session_cookie_name(kind, slug); cookie_value(headers, &name) @@ -63,6 +86,10 @@ pub fn clear_session_cookie() -> HeaderValue { clear_cookie(SESSION_COOKIE) } +pub(crate) fn clear_resource_access_cookie(name: &str) -> Option { + valid_resource_access_cookie_name(name).then(|| clear_cookie(name)) +} + pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response { let token = csrf_cookie_token(&headers) .filter(|value| valid_csrf_token(value)) @@ -176,7 +203,15 @@ pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool { fn resource_cookie_name(kind: &str, slug: &str) -> String { let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes()); - format!("__Host-rustpad_access_{}", hex::encode(&digest[..12])) + format!("{RESOURCE_ACCESS_COOKIE_PREFIX}{}", hex::encode(&digest[..12])) +} + +fn valid_resource_access_cookie_name(name: &str) -> bool { + name.strip_prefix(RESOURCE_ACCESS_COOKIE_PREFIX) + .is_some_and(|suffix| { + suffix.len() == RESOURCE_ACCESS_COOKIE_SUFFIX_LENGTH + && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) } fn share_session_cookie_name(kind: &str, slug: &str) -> String { diff --git a/src/tests/auth.rs b/src/tests/auth.rs new file mode 100644 index 0000000..7fe8a56 --- /dev/null +++ b/src/tests/auth.rs @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; +use crate::{ + database::Database, + state::AppState, + storage::Storage, +}; +use std::sync::Arc; + +async fn logout_test_state() -> SharedState { + let db = Database::connect("sqlite::memory:", 1) + .await + .expect("test database"); + crate::run_migrations(&db).await.expect("test migrations"); + Arc::new(AppState::new( + db, + "test".into(), + Storage::Local { + root: std::env::temp_dir().join("rustpad-logout-tests"), + }, + 1_000_000, + true, + 1_000_000, + 0, + None, + None, + true, + false, + false, + "error".into(), + 7, + 7, + 7, + None, + )) +} + +#[tokio::test] +async fn logout_only_revokes_current_browser_session_and_password_access() { + let state = logout_test_state().await; + sqlx::query( + "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)", + ) + .bind("Logout Test") + .bind("logout test") + .bind("logout@example.test") + .bind("logout@example.test") + .bind("password-hash") + .execute(state.db.pool()) + .await + .unwrap(); + + let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); + for session in ["current-session", "other-device-session"] { + sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)") + .bind(session) + .bind(&expires_at) + .execute(state.db.pool()) + .await + .unwrap(); + } + + let current_access_token = "a".repeat(64); + let other_access_token = "b".repeat(64); + for (token, slug) in [ + (current_access_token.as_str(), "current-pad"), + (other_access_token.as_str(), "other-pad"), + ] { + sqlx::query( + "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, 'pad', ?, ?)", + ) + .bind(hash_token(token)) + .bind(slug) + .bind(&expires_at) + .execute(state.db.pool()) + .await + .unwrap(); + } + + let access_cookie = crate::security::resource_cookie( + "pad", + "current-pad", + ¤t_access_token, + 7, + ); + let access_cookie_name = access_cookie + .to_str() + .unwrap() + .split_once('=') + .unwrap() + .0 + .to_string(); + let share_cookie_name = "__Host-rustpad_share_0123456789abcdef01234567"; + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&format!( + "{}=current-session; {}={}; {}=share-token", + crate::security::SESSION_COOKIE, + access_cookie_name, + current_access_token, + share_cookie_name, + )) + .unwrap(), + ); + + let response = match logout(State(state.clone()), headers).await { + Ok(response) => response, + Err(error) => panic!("logout failed: {}", error.message), + }; + + let current_session_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?") + .bind("current-session") + .fetch_one(state.db.pool()) + .await + .unwrap(); + let other_session_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?") + .bind("other-device-session") + .fetch_one(state.db.pool()) + .await + .unwrap(); + let current_access_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?", + ) + .bind(hash_token(¤t_access_token)) + .fetch_one(state.db.pool()) + .await + .unwrap(); + let other_access_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?", + ) + .bind(hash_token(&other_access_token)) + .fetch_one(state.db.pool()) + .await + .unwrap(); + let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1") + .fetch_one(state.db.pool()) + .await + .unwrap(); + + assert_eq!(current_session_count, 0); + assert_eq!(other_session_count, 1); + assert_eq!(current_access_count, 0); + assert_eq!(other_access_count, 1); + assert_eq!(user_count, 1); + + let set_cookies = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert!(set_cookies.iter().any(|value| { + value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE)) + })); + assert!(set_cookies.iter().any(|value| { + value.starts_with(&format!("{access_cookie_name}=;")) + })); + assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name))); +} diff --git a/src/tests/queries.rs b/src/tests/queries.rs index be08bca..6a9a070 100644 --- a/src/tests/queries.rs +++ b/src/tests/queries.rs @@ -19,6 +19,7 @@ fn every_backend_has_explicit_queries() { Q033, USER_LIST_WORKSPACES, USER_LIST_PADS, + RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, SHARE_LINK_SESSION_SOURCE, SHARE_SESSION_INSERT, SHARE_SESSION_PERMISSION, diff --git a/src/tests/security.rs b/src/tests/security.rs index bed705a..dfa4687 100644 --- a/src/tests/security.rs +++ b/src/tests/security.rs @@ -104,6 +104,42 @@ fn share_sessions_use_separate_scoped_opaque_cookies() { assert!(value.contains("SameSite=Lax")); } +#[test] +fn logout_cookie_discovery_only_selects_password_access_cookies() { + let valid_name = "__Host-rustpad_access_0123456789abcdef01234567"; + let valid_token = "a".repeat(64); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!( + "{SESSION_COOKIE}=session-token; {valid_name}={valid_token}; __Host-rustpad_share_0123456789abcdef01234567=share-token; __Host-rustpad_access_too-short={valid_token}" + )) + .unwrap(), + ); + + assert_eq!( + resource_access_cookies(&headers), + vec![(valid_name.to_string(), Some(valid_token))] + ); +} + +#[test] +fn malformed_password_access_token_is_cleared_but_not_revoked() { + let name = "__Host-rustpad_access_0123456789abcdef01234567"; + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("{name}=not-a-valid-token")).unwrap(), + ); + + assert_eq!( + resource_access_cookies(&headers), + vec![(name.to_string(), None)] + ); + let cleared = clear_resource_access_cookie(name).unwrap(); + assert!(cleared.to_str().unwrap().starts_with(&format!("{name}=;"))); +} + #[test] fn csrf_requires_matching_cookie_and_header() { let token = "a".repeat(CSRF_TOKEN_BYTES * 2); diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 0985413..a3d934f 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -19,6 +19,12 @@ const clearAuthSession = sessionStore.clearAuthSession || (() => { localStorage.removeItem("rustpad:nickname"); sessionStorage.removeItem("rustpad:nickname"); }); +const clearResourceAccessState = sessionStore.clearResourceAccessState || (() => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key); + } +}); function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) { const directoryLogin = loginMode && externalAuth; @@ -324,6 +330,7 @@ export async function logoutCurrentSession() { try { await api("/api/auth/logout", { method: "POST" }); } catch { } + clearResourceAccessState(); clearAuthSession(); } diff --git a/static/js/session.js b/static/js/session.js index 84c6d92..ecf3b0a 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -28,6 +28,12 @@ export function setAccessToken(resourceKind, resourceSlug, granted) { if (granted) localStorage.setItem(key, "1"); else localStorage.removeItem(key); } +export function clearResourceAccessState() { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key); + } +} // Remove plaintext passwords saved by the previous frontend version. for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i);