tokens and more
This commit is contained in:
+84
-42
@@ -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, ¬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<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user