fix security 1
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.40"
|
||||
version = "0.0.41"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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
@@ -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,
|
||||
¬e_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,
|
||||
¬e_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,
|
||||
¬e_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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user