diff --git a/.dockerignore b/.dockerignore index 5e6f628..303e3fa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,4 +11,5 @@ README.md Dockerfile* docker-compose*.yml migrate/ -scripts/*.txt \ No newline at end of file +scripts/*.txt +tests/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0e574e0..311654b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.14" +version = "0.2.15" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index bd94c8f..1cb59f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.14" +version = "0.2.15" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index d7bc9df..369846f 100644 --- a/README.md +++ b/README.md @@ -278,3 +278,21 @@ SQL is selected explicitly by database engine. Application code uses logical que - `src/queries/mysql.rs` PostgreSQL statements use native `$1`, `$2`, ... placeholders. Query text is not rewritten at runtime, and result-shape casts are defined independently for each engine. + +## Random API test data + +`tests/random_data.py` creates data only through RustPad's HTTP API. It logs in, obtains a CSRF token, creates workspaces and notes, and can seed the initial Markdown content from generated text or cached snapshots of random Wikipedia pages with Wikimedia images. + +```bash +export RUSTPAD_TEST_PASSWORD='test1234' +python3 tests/random_data.py \ + --ip localhost \ + --port 3000 \ + --source wikipedia \ + --notes 10000 \ + --workspaces 10 \ + --notes-in-workspaces 1000 \ + --user test +``` + +`--notes-in-workspaces` is applied to every workspace. The example creates 10,000 standalone notes and another 10,000 notes inside 10 workspaces. Wikipedia mode never falls back to generated content. Use `--wikipedia-images`, `--wikipedia-attempts`, `--workers`, `--source-pool-size`, `--scheme https`, `--base-url`, or `--dry-run` as needed. The login value may be a local account e-mail or an LDAP/AD username. diff --git a/src/api/mod.rs b/src/api/mod.rs index 29e4bf1..75de82d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -38,6 +38,7 @@ const MAX_NAME_LENGTH: usize = 80; const MIN_PASSWORD_LENGTH: usize = 8; const MAX_PASSWORD_LENGTH: usize = 128; const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; +const MAX_DOCUMENT_SIZE_BYTES: usize = 2_000_000; fn bearer_token(headers: &HeaderMap) -> Option<&str> { crate::security::session_token(headers) @@ -81,9 +82,9 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> { .map(str::trim) .filter(|value| { (16..=64).contains(&value.len()) - && value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) }) } @@ -109,9 +110,7 @@ fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool { pad.created_by_guest_id .as_deref() .zip(requester_guest_id(headers)) - .is_some_and(|(owner_guest_id, requester_guest_id)| { - owner_guest_id == requester_guest_id - }) + .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) } async fn has_write_permission( @@ -126,7 +125,8 @@ async fn has_write_permission( } let authorization = authorization_token(headers); if authorization != resource - && external_token_access_level(state, kind, slug, authorization).await? >= AccessLevel::Write + && external_token_access_level(state, kind, slug, authorization).await? + >= AccessLevel::Write { return Ok(true); } @@ -191,10 +191,7 @@ pub(crate) async fn markdown_file_references( }) .map(|file| MarkdownFileReference { filename: file.filename, - url: crate::file_urls::public_file_url( - state.files_public_url.as_deref(), - &file.url, - ), + url: crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url), mime_type: file.mime_type, }) .collect()) @@ -253,6 +250,8 @@ pub struct PublicTaskUpdateRequest { pub struct CreateNoteRequest { name: String, #[serde(default)] + content: Option, + #[serde(default)] password: Option, #[serde(default)] access_token: Option, @@ -385,14 +384,7 @@ async fn save_editor_settings( creator_can_manage_authorship: bool, payload: EditorSettingsRequest, ) -> Result, ApiError> { - if !has_write_permission( - state, - headers, - permission_kind, - permission_slug, - ) - .await? - { + if !has_write_permission(state, headers, permission_kind, permission_slug).await? { return Err(ApiError::forbidden( "Read and write access is required to save editor preferences", )); @@ -416,13 +408,13 @@ async fn save_editor_settings( let can_manage_authorship = if wants_global_update { creator_can_manage_authorship || crate::auth::is_resource_owner( - state, - permission_kind, - permission_slug, - user_session_token(headers), - ) - .await - .unwrap_or(false) + state, + permission_kind, + permission_slug, + user_session_token(headers), + ) + .await + .unwrap_or(false) } else { false }; @@ -433,7 +425,10 @@ async fn save_editor_settings( } let preferences = if wants_personal_update { - let user_id = user.as_ref().expect("personal preferences require a user").id; + let user_id = user + .as_ref() + .expect("personal preferences require a user") + .id; let mut preferences = db::load_editor_preferences(&state.db, user_id, resource) .await? .unwrap_or_default(); @@ -467,12 +462,8 @@ async fn save_editor_settings( }; let resource_settings = if wants_global_update { - let mut settings = db::load_resource_editor_settings( - &state.db, - settings_kind, - settings_slug, - ) - .await?; + let mut settings = + db::load_resource_editor_settings(&state.db, settings_kind, settings_slug).await?; if let Some(mode) = payload.authorship_mode { settings.authorship_mode = match mode.as_str() { "simple" => "simple".into(), @@ -567,7 +558,12 @@ pub async fn open_workspace( &state, &workspace_slug, payload.password.as_deref(), - resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), bearer_token(&headers), &headers, ) @@ -614,7 +610,12 @@ pub async fn create_note( &state, &workspace_slug, payload.password.as_deref(), - resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), bearer_token(&headers), &headers, ) @@ -628,13 +629,19 @@ pub async fn create_note( &state, "workspace", &workspace_slug, - resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), bearer_token(&headers), ) .await? }; require_write(level)?; let title = validate_name(&payload.name, "Note name")?; + let initial_content = validate_initial_content(payload.content.as_deref())?; let base = slugify(title); if base.is_empty() { return Err(ApiError::bad_request( @@ -670,6 +677,17 @@ pub async fn create_note( created_by_guest_id.as_deref(), ) .await?; + if let Some(content) = initial_content { + db::save_revision( + &state.db, + note.id, + workspace.id, + content, + created_by.as_deref(), + "[]", + ) + .await?; + } Ok(( StatusCode::CREATED, Json(NoteListItem { @@ -865,9 +883,8 @@ pub async fn set_note_editor_settings( let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; - let creator_can_manage_authorship = - note_creator_is_requester(&state, &headers, ¬e).await? - || has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; + let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, ¬e).await? + || has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; save_editor_settings( &state, &headers, @@ -893,7 +910,12 @@ pub async fn history( &workspace_slug, ¬e_slug, payload.password.as_deref(), - resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), bearer_token(&headers), &headers, ) @@ -921,7 +943,12 @@ pub async fn restore( &workspace_slug, ¬e_slug, payload.password.as_deref(), - resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), bearer_token(&headers), &headers, ) @@ -935,7 +962,12 @@ pub async fn restore( &state, "workspace", &workspace_slug, - resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), bearer_token(&headers), ) .await? @@ -1238,6 +1270,16 @@ fn validate_password(password: Option<&str>) -> Result, ApiError> { Ok(Some(password)) } +fn validate_initial_content(content: Option<&str>) -> Result, ApiError> { + let Some(content) = content.filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if content.len() > MAX_DOCUMENT_SIZE_BYTES { + return Err(ApiError::bad_request("The document is too large")); + } + Ok(Some(content)) +} + async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result { let base = slugify(title); if base.is_empty() { diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 5b37759..ac3851c 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -14,6 +14,8 @@ pub struct CreatePadRequest { name: String, #[serde(default)] password: Option, + #[serde(default)] + content: Option, } #[derive(Debug, Serialize)] @@ -58,6 +60,7 @@ pub async fn create_pad( ) -> Result<(StatusCode, Json), ApiError> { let title = validate_name(&payload.name, "Note name")?; let password = validate_password(payload.password.as_deref())?; + let initial_content = validate_initial_content(payload.content.as_deref())?; let base = slugify(title); if base.is_empty() { return Err(ApiError::bad_request( @@ -68,26 +71,23 @@ pub async fn create_pad( let account_user = crate::auth::optional_user(&state, &headers) .await .map_err(|e| ApiError::forbidden(&e.message))?; + let author = account_user.as_ref().map(|user| user.nickname.clone()); let created_by_guest_id = if account_user.is_none() { requester_guest_id(&headers) } else { None }; - let pad = db::create_pad( - &state.db, - &slug, - title, - password, - created_by_guest_id, - ) - .await?; - if let Some(user) = account_user { + let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?; + if let Some(user) = account_user.as_ref() { sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)) .bind(user.id) .bind(&pad.slug) .execute(state.db.pool()) .await?; } + if let Some(content) = initial_content { + db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?; + } Ok(( StatusCode::CREATED, Json(CreatePadResponse { @@ -105,31 +105,17 @@ pub async fn pad_info( let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; - ensure_private_resource_access( - &state, - &headers, - "pad", - &pad.slug, - pad.is_private, - ) - .await?; + ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; - let (editor_preferences, personal_editor_settings) = user_editor_preferences( - &state, - &headers, - db::EditorPreferenceResource::Pad(pad.id), - ) - .await?; + let (editor_preferences, personal_editor_settings) = + user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id)) + .await?; let resource_editor_settings = db::load_resource_editor_settings(&state.db, "pad", &slug).await?; - let account_owner = crate::auth::is_resource_owner( - &state, - "pad", - &slug, - user_session_token(&headers), - ) - .await - .unwrap_or(false); + let account_owner = + crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers)) + .await + .unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?; let can_manage_authorship = account_owner || guest_owner || password_write_access; @@ -432,15 +418,8 @@ async fn ensure_public_page_access( return Ok(()); } let password_ok = db::verify_workspace_password(&workspace, password); - check_resource_password_attempt( - state, - headers, - "workspace", - &slug, - password, - password_ok, - ) - .await?; + check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok) + .await?; if password_ok { return Ok(()); } @@ -462,13 +441,8 @@ pub async fn public_page( .await? .ok_or_else(ApiError::not_found_note)?; ensure_public_page_access(&state, &headers, &page).await?; - let files = markdown_file_references( - &state, - page.pad_id, - page.note_id, - Some(&page.content), - ) - .await?; + let files = + markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, @@ -496,13 +470,8 @@ pub async fn update_public_task( let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked) .await? .ok_or_else(ApiError::not_found_note)?; - let files = markdown_file_references( - &state, - page.pad_id, - page.note_id, - Some(&page.content), - ) - .await?; + let files = + markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, @@ -614,8 +583,7 @@ pub(super) async fn authorized_pad( } if pad.password_hash.is_some() && token_level < AccessLevel::Write { let password_ok = db::verify_pad_password(&pad, password); - check_resource_password_attempt(state, headers, "pad", slug, password, password_ok) - .await?; + check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?; if token_level == AccessLevel::None && !password_ok { return Err(ApiError::forbidden("Password required or incorrect.")); } diff --git a/src/app/mod.rs b/src/app/mod.rs index d44ef41..22f7f0b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,11 +10,12 @@ mod pages; use axum::{ + Json, Router, extract::{DefaultBodyLimit, Request}, - http::{HeaderName, HeaderValue, StatusCode, header}, + http::{HeaderName, HeaderValue, Method, StatusCode, header}, middleware::{self, Next}, - response::Response, + response::{IntoResponse, Response}, routing::{get, post}, }; use pages::*; @@ -75,6 +76,7 @@ pub fn router( get(api::download_legacy_file), ) .route("/api/auth/identity", post(auth::identity)) + .route("/api/security/csrf", get(crate::security::csrf_token_endpoint)) .route("/api/access-token", post(api::create_resource_access_token)) .route("/api/auth/register", post(auth::register)) .route("/api/auth/login", post(auth::login)) @@ -221,10 +223,29 @@ pub fn router( HeaderValue::from_static("same-origin"), )) .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn(require_csrf_token)) .layer(middleware::from_fn(add_non_asset_security_headers)) .with_state(state) } +async fn require_csrf_token(request: Request, next: Next) -> Response { + let method = request.method(); + let unsafe_method = method == Method::POST + || method == Method::PUT + || method == Method::PATCH + || method == Method::DELETE; + if unsafe_method && !crate::security::csrf_request_is_valid(request.headers()) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "Security token is missing or expired. Refresh the page and try again." + })), + ) + .into_response(); + } + next.run(request).await +} + async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { let path = request.uri().path(); let is_asset = path.starts_with("/assets/"); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index cfc53c6..c8fb905 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -444,7 +444,8 @@ pub async fn register( "Account created. Check your e-mail and confirm the account before logging in." .into(), }), - ).into_response()); + ) + .into_response()); } let session = create_session(&state, &user).await?; @@ -461,7 +462,8 @@ pub async fn register( theme: session.theme, message: "Account created.".into(), }), - ).into_response(); + ) + .into_response(); response.headers_mut().insert(header::SET_COOKIE, cookie); Ok(response) } @@ -479,15 +481,19 @@ pub async fn login( state .check_rate_limit(client_limit_key.clone(), 30, window) .await - .map_err(|seconds| AuthError::rate_limited(&format!( - "Too many login attempts. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many login attempts. Try again in {seconds} seconds." + )) + })?; state .check_rate_limit(limit_key.clone(), 5, window) .await - .map_err(|seconds| AuthError::rate_limited(&format!( - "Too many login attempts. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many login attempts. Try again in {seconds} seconds." + )) + })?; let session = if state.ldap.is_some() { ldap::login(&state, &req.email, &req.password).await? } else { @@ -1816,9 +1822,11 @@ pub async fn request_reset( state .check_rate_limit(format!("password-reset-client:{client_key}"), 10, window) .await - .map_err(|seconds| AuthError::rate_limited(&format!( - "Too many password reset requests. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many password reset requests. Try again in {seconds} seconds." + )) + })?; state .check_rate_limit( format!("password-reset:{client_key}:{}", normalize(&email)), @@ -1826,9 +1834,11 @@ pub async fn request_reset( window, ) .await - .map_err(|seconds| AuthError::rate_limited(&format!( - "Too many password reset requests. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many password reset requests. Try again in {seconds} seconds." + )) + })?; info!(email_domain = %email_domain(&email), "password reset requested"); let smtp = state.smtp.as_ref().ok_or_else(|| { AuthError::service_unavailable("Password reset is not configured on this server.") @@ -1883,15 +1893,19 @@ pub async fn confirm_reset( state .check_rate_limit(client_limit_key.clone(), 20, window) .await - .map_err(|seconds| AuthError::rate_limited(&format!( - "Too many reset attempts. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many reset attempts. Try again in {seconds} seconds." + )) + })?; state .check_rate_limit(limit_key.clone(), 10, window) .await - .map_err(|seconds| AuthError::rate_limited(&format!( - "Too many reset attempts. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + AuthError::rate_limited(&format!( + "Too many reset attempts. Try again in {seconds} seconds." + )) + })?; info!("password reset confirmation requested"); let now_time = Utc::now(); let now = now_time.to_rfc3339(); diff --git a/src/security.rs b/src/security.rs index 6854c73..fae6971 100644 --- a/src/security.rs +++ b/src/security.rs @@ -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")); + } } diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index bb9a2a4..e000e4f 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -7,11 +7,12 @@ use axum::{ Path, State, WebSocketUpgrade, ws::{Message, WebSocket}, }, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; @@ -19,6 +20,92 @@ mod pad; pub use pad::upgrade_pad; +const HEARTBEAT_INTERVAL_MS: u64 = 10_000; +const HEARTBEAT_TIMEOUT_MS: u64 = 30_000; +const MAX_RECONNECT_DELAY_MS: u64 = 12_000; +const LATENCY_SAMPLE_WINDOW: usize = 20; + +#[derive(Debug, Clone, Default, Deserialize)] +struct ClientDiagnostics { + #[serde(default)] + language: Option, + #[serde(default)] + timezone: Option, + #[serde(default)] + platform: Option, + #[serde(default)] + effective_type: Option, + #[serde(default)] + downlink_mbps: Option, + #[serde(default)] + network_rtt_ms: Option, + #[serde(default)] + save_data: Option, +} + +#[derive(Debug, Clone)] +struct RequestClientContext { + client_id: String, + user_agent: Option, + accept_language: Option, + request_scheme: Option, +} + +impl RequestClientContext { + fn from_headers(headers: &HeaderMap, client_key: &str) -> Self { + let digest = Sha256::digest(client_key.as_bytes()); + Self { + client_id: hex::encode(&digest[..8]), + user_agent: diagnostic_header(headers, header::USER_AGENT.as_str(), 180), + accept_language: diagnostic_header(headers, header::ACCEPT_LANGUAGE.as_str(), 100), + request_scheme: diagnostic_header(headers, "x-forwarded-proto", 12).or_else(|| { + headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split_once("://").map(|(scheme, _)| scheme)) + .and_then(|value| clean_diagnostic_text(Some(value.to_owned()), 12)) + }), + } + } +} + +#[derive(Debug, Serialize)] +struct QualityThresholds { + excellent_max_ms: u64, + good_max_ms: u64, + degraded_max_ms: u64, +} + +#[derive(Debug, Serialize)] +struct DiagnosticClient { + id: String, + user_agent: Option, + accept_language: Option, + request_scheme: Option, + language: Option, + timezone: Option, + platform: Option, + effective_type: Option, + downlink_mbps: Option, + network_rtt_ms: Option, + save_data: Option, +} + +#[derive(Debug, Serialize)] +struct ConnectionDiagnostics { + connection_id: u64, + connected_at: String, + server_time: String, + server_version: &'static str, + transport: &'static str, + heartbeat_interval_ms: u64, + heartbeat_timeout_ms: u64, + max_reconnect_delay_ms: u64, + latency_sample_window: usize, + quality_thresholds: QualityThresholds, + client: DiagnosticClient, +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientMessage { @@ -28,6 +115,8 @@ enum ClientMessage { nickname: Option, guest_id: Option, color: Option, + #[serde(default)] + diagnostics: Option, }, Update { content: String, @@ -71,11 +160,79 @@ enum ServerMessage { Pong { nonce: u64, }, + Diagnostics { + diagnostics: ConnectionDiagnostics, + }, Error { message: String, }, } +fn connection_diagnostics( + connection_id: u64, + request: &RequestClientContext, + client: Option, +) -> ConnectionDiagnostics { + let client = client.unwrap_or_default(); + let now = chrono::Utc::now().to_rfc3339(); + ConnectionDiagnostics { + connection_id, + connected_at: now.clone(), + server_time: now, + server_version: env!("CARGO_PKG_VERSION"), + transport: "websocket", + heartbeat_interval_ms: HEARTBEAT_INTERVAL_MS, + heartbeat_timeout_ms: HEARTBEAT_TIMEOUT_MS, + max_reconnect_delay_ms: MAX_RECONNECT_DELAY_MS, + latency_sample_window: LATENCY_SAMPLE_WINDOW, + quality_thresholds: QualityThresholds { + excellent_max_ms: 100, + good_max_ms: 250, + degraded_max_ms: 600, + }, + client: DiagnosticClient { + id: request.client_id.clone(), + user_agent: request.user_agent.clone(), + accept_language: request.accept_language.clone(), + request_scheme: request.request_scheme.clone(), + language: clean_diagnostic_text(client.language, 40), + timezone: clean_diagnostic_text(client.timezone, 80), + platform: clean_diagnostic_text(client.platform, 80), + effective_type: clean_diagnostic_text(client.effective_type, 20), + downlink_mbps: client + .downlink_mbps + .filter(|value| value.is_finite()) + .map(|value| value.clamp(0.0, 10_000.0)), + network_rtt_ms: client.network_rtt_ms.map(|value| value.min(120_000)), + save_data: client.save_data, + }, + } +} + +fn diagnostic_header(headers: &HeaderMap, name: &str, max_chars: usize) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .and_then(|value| clean_diagnostic_text(Some(value), max_chars)) +} + +fn clean_diagnostic_text(value: Option, max_chars: usize) -> Option { + value + .map(|value| { + value + .trim() + .chars() + .filter(|character| !character.is_control()) + .take(max_chars) + .collect::() + }) + .filter(|value| !value.is_empty()) +} + async fn resource_permission_from_tokens( state: &SharedState, kind: &str, @@ -125,9 +282,10 @@ pub async fn upgrade( return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response(); } let account_token = crate::security::session_token(&headers).map(str::to_owned); - let resource_token = crate::security::resource_token(&headers, "workspace", &workspace_slug) - .map(str::to_owned); + let resource_token = + crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned); let client_key = crate::security::client_key(&headers); + let client_context = RequestClientContext::from_headers(&headers, &client_key); ws.on_upgrade(move |socket| { handle_socket( socket, @@ -137,6 +295,7 @@ pub async fn upgrade( account_token, resource_token, client_key, + client_context, ) }) } @@ -149,6 +308,7 @@ async fn handle_socket( cookie_session_token: Option, cookie_access_token: Option, client_key: String, + client_context: RequestClientContext, ) { info!(%workspace_slug, %note_slug, "note websocket connected"); let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) @@ -169,7 +329,7 @@ async fn handle_socket( let _ = send_error(&mut socket, "Note not found").await; return; }; - let (password, access_token, nickname, guest_id, color) = + let (password, access_token, nickname, guest_id, color, client_diagnostics) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { Ok(ClientMessage::Authenticate { @@ -178,12 +338,14 @@ async fn handle_socket( nickname, guest_id, color, + diagnostics, }) => ( password, access_token, clean_nickname(nickname), clean_guest_id(guest_id), clean_color(color), + diagnostics, ), _ => { let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; @@ -249,7 +411,11 @@ async fn handle_socket( .check_rate_limit(format!("resource-password-client:{client_key}"), 50, window) .await { - let _ = send_error(&mut socket, &format!("Too many password attempts. Try again in {seconds} seconds.")).await; + let _ = send_error( + &mut socket, + &format!("Too many password attempts. Try again in {seconds} seconds."), + ) + .await; return; } if let Err(seconds) = state @@ -319,6 +485,19 @@ async fn handle_socket( let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); let (mut sender, mut receiver) = socket.split(); + if send_split( + &mut sender, + &ServerMessage::Diagnostics { + diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics), + }, + ) + .await + .is_err() + { + let users = state.leave_room(&room_key, connection_id).await; + let _ = channel.send(RoomEvent::Presence(users)); + return; + } loop { tokio::select! { incoming=receiver.next()=>match incoming { diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index 0a5407e..291fda8 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -26,6 +26,9 @@ enum PadServerMessage { Pong { nonce: u64, }, + Diagnostics { + diagnostics: ConnectionDiagnostics, + }, Error { message: String, }, @@ -41,11 +44,19 @@ pub async fn upgrade_pad( return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response(); } let account_token = crate::security::session_token(&headers).map(str::to_owned); - let resource_token = crate::security::resource_token(&headers, "pad", &slug) - .map(str::to_owned); + let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned); let client_key = crate::security::client_key(&headers); + let client_context = RequestClientContext::from_headers(&headers, &client_key); ws.on_upgrade(move |socket| { - handle_pad_socket(socket, state, slug, account_token, resource_token, client_key) + handle_pad_socket( + socket, + state, + slug, + account_token, + resource_token, + client_key, + client_context, + ) }) } async fn handle_pad_socket( @@ -55,6 +66,7 @@ async fn handle_pad_socket( cookie_session_token: Option, cookie_access_token: Option, client_key: String, + client_context: RequestClientContext, ) { info!(%slug, "pad websocket connected"); let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else { @@ -68,7 +80,7 @@ async fn handle_pad_socket( .await; return; }; - let (password, access_token, nickname, guest_id, color) = + let (password, access_token, nickname, guest_id, color, client_diagnostics) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { Ok(ClientMessage::Authenticate { @@ -77,12 +89,14 @@ async fn handle_pad_socket( nickname, guest_id, color, + diagnostics, }) => ( password, access_token, clean_nickname(nickname), clean_guest_id(guest_id), clean_color(color), + diagnostics, ), _ => { let _ = send_pad( @@ -132,13 +146,7 @@ async fn handle_pad_socket( ) .await; let anonymous_token_ok = permission.is_none() - && anonymous_access_from_tokens( - &state, - "pad", - &slug, - access_token.as_deref(), - ) - .await; + && anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await; let password_limit_key = format!("resource-password:{client_key}:pad:{slug}"); let password_attempted = password .as_deref() @@ -157,9 +165,7 @@ async fn handle_pad_socket( let _ = send_pad( &mut socket, &PadServerMessage::Error { - message: format!( - "Too many password attempts. Try again in {seconds} seconds." - ), + message: format!("Too many password attempts. Try again in {seconds} seconds."), }, ) .await; @@ -172,9 +178,7 @@ async fn handle_pad_socket( let _ = send_pad( &mut socket, &PadServerMessage::Error { - message: format!( - "Too many password attempts. Try again in {seconds} seconds." - ), + message: format!("Too many password attempts. Try again in {seconds} seconds."), }, ) .await; @@ -239,6 +243,19 @@ async fn handle_pad_socket( let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); let (mut sender, mut receiver) = socket.split(); + if send_pad_split( + &mut sender, + &PadServerMessage::Diagnostics { + diagnostics: connection_diagnostics(connection_id, &client_context, client_diagnostics), + }, + ) + .await + .is_err() + { + let users = state.leave_room(&room_key, connection_id).await; + let _ = channel.send(RoomEvent::Presence(users)); + return; + } loop { tokio::select! { incoming=receiver.next()=>match incoming{ diff --git a/static/css/styles.css b/static/css/styles.css index 80fc54b..894a7d1 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -2772,6 +2772,110 @@ dialog::backdrop { font-size: inherit; } +.footer-connection-block { + display: inline-flex; + align-items: center; + gap: 4px; +} + +:is(.connection-details, .mobile-connection-details) { + position: relative; +} + +:is(.connection-details, .mobile-connection-details)>summary { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted-2); + cursor: pointer; + list-style: none; +} + +:is(.connection-details, .mobile-connection-details)>summary::-webkit-details-marker { + display: none; +} + +.connection-details__chevron { + font-size: .68rem; + transition: transform .16s ease; +} + +.connection-details[open] .connection-details__chevron { + transform: rotate(180deg); +} + +.connection-diagnostics-popover { + position: absolute; + z-index: 55; + left: 0; + bottom: calc(100% + 9px); + width: min(430px, calc(100vw - 24px)); + padding: 12px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--surface-card); + box-shadow: 0 16px 40px var(--shadow-45); + color: var(--text); + font-family: inherit; + white-space: normal; +} + +.connection-diagnostics-popover>strong { + display: block; + margin-bottom: 9px; + font-size: .78rem; +} + +.connection-diagnostics-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin: 0; +} + +.connection-diagnostics-grid>div { + min-width: 0; + padding: 7px 8px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface-inset); +} + +.connection-diagnostics-grid__wide { + grid-column: 1 / -1; +} + +.connection-diagnostics-grid dt { + margin: 0 0 3px; + color: var(--muted); + font-size: .62rem; + font-weight: 700; + letter-spacing: .05em; + text-transform: uppercase; +} + +.connection-diagnostics-grid dd { + overflow-wrap: anywhere; + margin: 0; + color: var(--text); + font-family: inherit; + font-size: .7rem; + font-weight: 600; + line-height: 1.35; +} + +:is(.connection-details, .mobile-connection-details):is(.is-quality-excellent, .is-quality-good) [data-connection-diagnostic="quality"] { + color: var(--success); +} + +:is(.connection-details, .mobile-connection-details).is-quality-degraded [data-connection-diagnostic="quality"] { + color: var(--warning); +} + +:is(.connection-details, .mobile-connection-details).is-quality-poor [data-connection-diagnostic="quality"] { + color: var(--danger); +} + .markdown-more { position: relative; } @@ -5160,18 +5264,46 @@ dialog::backdrop { align-items: center; gap: 5px; min-width: 0; + min-height: 34px; padding: 0 8px 0 4px; + border-radius: 999px; + background: var(--wash-hover); color: var(--muted); font-size: .72rem; white-space: nowrap; } + .mobile-connection-details { + flex: 0 0 auto; + } + + .mobile-connection-details[open]>.mobile-connection-status { + background: color-mix(in srgb, var(--accent) 24%, var(--wash-hover)); + } + .mobile-connection-status #mobile-status-text { max-width: 92px; overflow: hidden; text-overflow: ellipsis; } + #mobile-socket-latency { + color: var(--muted-2); + font-size: .66rem; + } + + .mobile-connection-diagnostics-popover { + position: fixed; + right: 12px; + bottom: calc(64px + env(safe-area-inset-bottom, 0px)); + left: auto; + width: min(430px, calc(100vw - 24px)); + max-height: calc(100dvh - 88px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + } + .pad-page .toast { bottom: calc(62px + env(safe-area-inset-bottom, 0px)); } @@ -5303,7 +5435,7 @@ dialog::backdrop { @media (max-width: 720px) { .editor-column-label { - align-items: flex-start; + align-items: center; } .authorship-mode-control button { @@ -6154,6 +6286,7 @@ dialog::backdrop { .pad-page .participant-badges:empty { display: none; } + /* Unified RustPad resource identity used by notes and workspaces. */ .resource-brand { display: grid; @@ -6222,4 +6355,4 @@ dialog::backdrop { padding-left: 0; font-size: .68rem; } -} +} \ No newline at end of file diff --git a/static/editor.html b/static/editor.html index f9fe09c..512107a 100644 --- a/static/editor.html +++ b/static/editor.html @@ -163,10 +163,64 @@