fix security 1

This commit is contained in:
Mateusz Gruszczyński
2026-07-26 16:13:31 +02:00
parent 9b89ef79c4
commit a075d06b6d
5 changed files with 191 additions and 22 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.0.40"
version = "0.0.41"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.0.40"
version = "0.0.41"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+155 -12
View File
@@ -1,7 +1,7 @@
use axum::{
Json,
extract::{Multipart, Path, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use chrono::{Duration, Utc};
@@ -232,6 +232,12 @@ pub async fn create_note(
bearer_token(&headers),
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let title = validate_name(&payload.name, "Note name")?;
let base = slugify(title);
if base.is_empty() {
@@ -366,6 +372,12 @@ pub async fn restore(
bearer_token(&headers),
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
.bind(payload.revision_id)
.bind(note.id)
@@ -395,6 +407,86 @@ pub async fn restore(
Ok(Json(serde_json::json!({"ok": true})))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccessLevel {
None,
Read,
Write,
}
fn permission_level(permission: Option<&str>) -> AccessLevel {
match permission {
Some("rw") => AccessLevel::Write,
Some("ro") => AccessLevel::Read,
_ => AccessLevel::None,
}
}
async fn anonymous_access_token_valid(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<bool, ApiError> {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false);
};
let count: i64 = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
))
.bind(hash_access_token(token))
.bind(kind)
.bind(slug)
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.await?;
Ok(count > 0)
}
async fn token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::resource_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
let level = permission_level(permission.as_deref());
if level != AccessLevel::None {
return Ok(level);
}
if anonymous_access_token_valid(state, kind, slug, token).await? {
// A server-issued token created after a correct resource password
// retains the historical read/write semantics of password access.
return Ok(AccessLevel::Write);
}
Ok(AccessLevel::None)
}
async fn combined_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
bearer: Option<&str>,
) -> Result<AccessLevel, ApiError> {
Ok(std::cmp::max(
token_access_level(state, kind, slug, access_token).await?,
token_access_level(state, kind, slug, bearer).await?,
))
}
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
if level >= AccessLevel::Write {
Ok(())
} else {
Err(ApiError::forbidden("Read-only access."))
}
}
async fn ensure_private_resource_access(
state: &SharedState,
kind: &str,
@@ -421,14 +513,15 @@ pub async fn authorized_workspace(
let workspace = db::find_workspace(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?
|| verify_resource_access_token(state, "workspace", slug, bearer).await?;
if workspace.is_private != 0 && !token_access {
let token_level = combined_token_access_level(
state, "workspace", slug, access_token, bearer,
).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This workspace is private."));
}
if workspace.password_hash.is_some()
&& !db::verify_workspace_password(&workspace, password)
&& !token_access
&& token_level == AccessLevel::None
{
return Err(ApiError::unauthorized());
}
@@ -635,6 +728,12 @@ pub async fn publish_pad_page(
bearer_token(&headers),
)
.await?;
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let token = db::publish_pad(&state.db, pad.id).await?;
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
Ok(Json(PublishResponse {
@@ -648,7 +747,7 @@ pub async fn publish_note_page(
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PublishRequest>,
) -> Result<Json<PublishResponse>, ApiError> {
let (_, note) = authorized_note(
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
@@ -657,6 +756,12 @@ pub async fn publish_note_page(
bearer_token(&headers),
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let token = db::publish_note(&state.db, note.id).await?;
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
Ok(Json(PublishResponse {
@@ -742,6 +847,12 @@ pub async fn pad_restore(
bearer_token(&headers),
)
.await?;
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029))
.bind(payload.revision_id)
.bind(pad.id)
@@ -781,12 +892,16 @@ async fn authorized_pad(
let pad = db::find_pad(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?
|| verify_resource_access_token(state, "pad", slug, bearer).await?;
if pad.is_private != 0 && !token_access {
let token_level = combined_token_access_level(
state, "pad", slug, access_token, bearer,
).await?;
if pad.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This note is private."));
}
if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access {
if pad.password_hash.is_some()
&& !db::verify_pad_password(&pad, password)
&& token_level == AccessLevel::None
{
return Err(ApiError::unauthorized());
}
Ok(pad)
@@ -847,6 +962,12 @@ pub async fn upload_pad_file(
}
}
let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers)).await?;
let level = if db::verify_pad_password(&pad, password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "pad", &slug, access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
let safe = sanitize_filename(&original);
let file_token = db::pad_file_token(&state.db, pad.id).await?;
@@ -986,7 +1107,7 @@ pub async fn upload_note_file(
file = Some((filename, bytes.to_vec()));
}
}
let (_workspace, note) = authorized_note(
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
@@ -995,6 +1116,13 @@ pub async fn upload_note_file(
bearer_token(&headers),
)
.await?;
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "workspace", &workspace_slug, access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
let safe = sanitize_filename(&original);
let file_token = db::note_file_token(&state.db, note.id).await?;
@@ -1038,7 +1166,7 @@ pub async fn delete_note(
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (_workspace, note) = authorized_note(
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
@@ -1047,6 +1175,13 @@ pub async fn delete_note(
bearer_token(&headers),
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{ AccessLevel::Write } else {
combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await?
};
require_write(level)?;
if note.protected {
return Err(ApiError::bad_request(
"This note is protected and cannot be deleted",
@@ -1199,6 +1334,14 @@ async fn serve_token_file(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.headers_mut().insert(
HeaderName::from_static("x-robots-tag"),
HeaderValue::from_static("noindex, nofollow, noarchive, nosnippet"),
);
response.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_str(&format!(
+10
View File
@@ -45,6 +45,7 @@ pub fn router(
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health))
.route("/robots.txt", get(robots_txt))
.route("/f/{token}/{filename}", get(api::download_file))
.route(
"/files/{directory}/{filename}",
@@ -175,6 +176,15 @@ async fn health() -> &'static str {
"ok"
}
async fn robots_txt() -> Response {
let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}
async fn home(State(state): State<SharedState>) -> Response {
assets::render_html(
include_str!("../static/home.html"),
+24 -8
View File
@@ -148,21 +148,26 @@ async fn handle_socket(
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let supplied_token = session_token.as_deref().or(access_token.as_deref());
let permission = auth::resource_permission(
&state,
"workspace",
&workspace_slug,
session_token.as_deref().or(access_token.as_deref()),
supplied_token,
)
.await
.ok()
.flatten();
let anonymous_token_ok = permission.is_none()
&& crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, supplied_token)
.await
.unwrap_or(false);
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
if workspace.is_private != 0 && permission.is_none() {
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_error(&mut socket, "This workspace is private").await;
return;
}
if workspace.password_hash.is_some() && !password_ok && permission.is_none() {
if workspace.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok {
warn!(
workspace_id = workspace.id,
note_id = note.id,
@@ -171,7 +176,10 @@ async fn handle_socket(
let _ = send_error(&mut socket, "Invalid password").await;
return;
}
let write_allowed = password_ok || permission.as_deref() != Some("ro");
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none());
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
if send(
&mut socket,
@@ -398,17 +406,22 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let supplied_token = session_token.as_deref().or(access_token.as_deref());
let permission = auth::resource_permission(
&state,
"pad",
&slug,
session_token.as_deref().or(access_token.as_deref()),
supplied_token,
)
.await
.ok()
.flatten();
let anonymous_token_ok = permission.is_none()
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
.await
.unwrap_or(false);
let password_ok = db::verify_pad_password(&pad, password.as_deref());
if pad.is_private != 0 && permission.is_none() {
if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
@@ -418,7 +431,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
.await;
return;
}
if pad.password_hash.is_some() && !password_ok && permission.is_none() {
if pad.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok {
warn!(pad_id = pad.id, "pad websocket rejected: invalid password");
let _ = send_pad(
&mut socket,
@@ -429,7 +442,10 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
.await;
return;
}
let write_allowed = password_ok || permission.as_deref() != Some("ro");
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
if send_pad(
&mut socket,