tokens and more

This commit is contained in:
Mateusz Gruszczyński
2026-08-01 00:15:37 +02:00
parent 6c5232ccc5
commit 1401054c71
18 changed files with 1966 additions and 285 deletions
+84 -42
View File
@@ -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<String>,
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
@@ -385,14 +384,7 @@ async fn save_editor_settings(
creator_can_manage_authorship: bool,
payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, 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, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let creator_can_manage_authorship =
note_creator_is_requester(&state, &headers, &note).await?
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, &note).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,
&note_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,
&note_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<Option<&str>, ApiError> {
Ok(Some(password))
}
fn validate_initial_content(content: Option<&str>) -> Result<Option<&str>, 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<String, ApiError> {
let base = slugify(title);
if base.is_empty() {
+24 -56
View File
@@ -14,6 +14,8 @@ pub struct CreatePadRequest {
name: String,
#[serde(default)]
password: Option<String>,
#[serde(default)]
content: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -58,6 +60,7 @@ pub async fn create_pad(
) -> Result<(StatusCode, Json<CreatePadResponse>), 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."));
}
+23 -2
View File
@@ -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/");
+34 -20
View File
@@ -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();
+118 -1
View File
@@ -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"));
}
}
+184 -5
View File
@@ -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<String>,
#[serde(default)]
timezone: Option<String>,
#[serde(default)]
platform: Option<String>,
#[serde(default)]
effective_type: Option<String>,
#[serde(default)]
downlink_mbps: Option<f64>,
#[serde(default)]
network_rtt_ms: Option<u64>,
#[serde(default)]
save_data: Option<bool>,
}
#[derive(Debug, Clone)]
struct RequestClientContext {
client_id: String,
user_agent: Option<String>,
accept_language: Option<String>,
request_scheme: Option<String>,
}
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<String>,
accept_language: Option<String>,
request_scheme: Option<String>,
language: Option<String>,
timezone: Option<String>,
platform: Option<String>,
effective_type: Option<String>,
downlink_mbps: Option<f64>,
network_rtt_ms: Option<u64>,
save_data: Option<bool>,
}
#[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<String>,
guest_id: Option<String>,
color: Option<String>,
#[serde(default)]
diagnostics: Option<ClientDiagnostics>,
},
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<ClientDiagnostics>,
) -> 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<String> {
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<String>, max_chars: usize) -> Option<String> {
value
.map(|value| {
value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>()
})
.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<String>,
cookie_access_token: Option<String>,
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::<ClientMessage>(&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 {
+34 -17
View File
@@ -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<String>,
cookie_access_token: Option<String>,
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::<ClientMessage>(&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{