security upgrade
This commit is contained in:
+24
-56
@@ -16,6 +16,7 @@ pub async fn upload_pad_file(
|
||||
Path(slug): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
require_upload_permission(&state, &headers).await?;
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
@@ -60,14 +61,6 @@ pub async fn upload_pad_file(
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
require_upload_permission(
|
||||
&state,
|
||||
&headers,
|
||||
"pad",
|
||||
&slug,
|
||||
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_pad_password(&pad, password.as_deref())
|
||||
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
||||
{
|
||||
@@ -190,10 +183,18 @@ pub async fn delete_pad_file(
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let account_owner = crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"pad",
|
||||
&pad.slug,
|
||||
bearer_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", &pad.slug).await?;
|
||||
if !account_owner && !guest_owner && !password_write_access {
|
||||
return Err(ApiError::forbidden("Only the note owner can delete files"));
|
||||
}
|
||||
let file = db::find_pad_file(&state.db, pad.id, file_id)
|
||||
@@ -212,6 +213,7 @@ pub async fn upload_note_file(
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
require_upload_permission(&state, &headers).await?;
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
@@ -258,20 +260,6 @@ pub async fn upload_note_file(
|
||||
)
|
||||
.await?;
|
||||
|
||||
require_upload_permission(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
resource_request_token(
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
access_token.as_deref(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|
||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||
{
|
||||
@@ -433,19 +421,12 @@ pub async fn delete_note_file(
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let note_owner = crate::auth::optional_user(&state, &headers)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|user| {
|
||||
note.created_by
|
||||
.as_deref()
|
||||
.map(|creator| creator == user.nickname)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !workspace_owner && !note_owner {
|
||||
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||
let password_write_access =
|
||||
has_password_write_access(&state, &headers, "workspace", &workspace.slug).await?;
|
||||
if !workspace_owner && !note_owner && !password_write_access {
|
||||
return Err(ApiError::forbidden(
|
||||
"Only the note owner or workspace owner can delete files",
|
||||
"Only the note owner, workspace owner, or password holder can delete files",
|
||||
));
|
||||
}
|
||||
let file = db::find_note_file(&state.db, note.id, file_id)
|
||||
@@ -461,29 +442,16 @@ pub async fn delete_note_file(
|
||||
async fn require_upload_permission(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
resource_token: Option<&str>,
|
||||
) -> Result<(), ApiError> {
|
||||
let permission = crate::auth::share_link_permission(state, kind, slug, resource_token)
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||
if permission.as_deref() == Some("rw") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user = crate::auth::optional_user(state, headers)
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||
if user.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match permission.as_deref() {
|
||||
Some("ro") => Err(ApiError::forbidden("Read-only access.")),
|
||||
_ => Err(ApiError::forbidden(
|
||||
"Log in or use a read-write share link to upload files.",
|
||||
)),
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::forbidden(
|
||||
"Log in with read-write access to upload files.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+116
-31
@@ -76,6 +76,44 @@ async fn session_user(
|
||||
.map_err(|error| ApiError::forbidden(&error.message))
|
||||
}
|
||||
|
||||
fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
|
||||
crate::security::cookie_value(headers, "rustpad_guest_id")
|
||||
.map(str::trim)
|
||||
.filter(|value| {
|
||||
(16..=64).contains(&value.len())
|
||||
&& value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
||||
})
|
||||
}
|
||||
|
||||
async fn note_creator_is_requester(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
note: &db::Note,
|
||||
) -> Result<bool, ApiError> {
|
||||
if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
|
||||
return Ok(requester_guest_id(headers)
|
||||
.is_some_and(|requester_guest_id| requester_guest_id == owner_guest_id));
|
||||
}
|
||||
let Some(user) = session_user(state, headers).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(note
|
||||
.created_by
|
||||
.as_deref()
|
||||
.is_some_and(|creator| creator == user.nickname))
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
async fn has_write_permission(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
@@ -94,9 +132,21 @@ async fn has_write_permission(
|
||||
}
|
||||
let session = crate::security::session_cookie_token(headers);
|
||||
if session != resource && session != authorization {
|
||||
return Ok(account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
|
||||
if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
match kind {
|
||||
"workspace" => Ok(db::find_workspace(&state.db, slug)
|
||||
.await?
|
||||
.is_some_and(|workspace| {
|
||||
workspace.is_private == 0 && workspace.password_hash.is_none()
|
||||
})),
|
||||
"pad" => Ok(db::find_pad(&state.db, slug)
|
||||
.await?
|
||||
.is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())),
|
||||
_ => Ok(false),
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -262,6 +312,7 @@ pub struct NoteInfo {
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
can_delete_files: bool,
|
||||
can_upload_files: bool,
|
||||
global_color: Option<String>,
|
||||
note_color: Option<String>,
|
||||
authorship_mode: String,
|
||||
@@ -327,6 +378,7 @@ async fn save_editor_settings(
|
||||
settings_kind: &str,
|
||||
settings_slug: &str,
|
||||
resource: db::EditorPreferenceResource,
|
||||
creator_can_manage_authorship: bool,
|
||||
payload: EditorSettingsRequest,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !has_write_permission(
|
||||
@@ -341,10 +393,6 @@ async fn save_editor_settings(
|
||||
"Read and write access is required to save editor preferences",
|
||||
));
|
||||
}
|
||||
let user = session_user(state, headers)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?;
|
||||
|
||||
let wants_personal_update = payload.compact_view.is_some()
|
||||
|| payload.editor_line_numbers.is_some()
|
||||
|| payload.preview_line_numbers.is_some()
|
||||
@@ -355,8 +403,15 @@ async fn save_editor_settings(
|
||||
if !wants_personal_update && !wants_global_update {
|
||||
return Err(ApiError::bad_request("No editor settings were provided"));
|
||||
}
|
||||
let user = session_user(state, headers).await?;
|
||||
if wants_personal_update && user.is_none() {
|
||||
return Err(ApiError::forbidden(
|
||||
"Log in to save personal editor preferences",
|
||||
));
|
||||
}
|
||||
let can_manage_authorship = if wants_global_update {
|
||||
crate::auth::is_resource_owner(
|
||||
creator_can_manage_authorship
|
||||
|| crate::auth::is_resource_owner(
|
||||
state,
|
||||
permission_kind,
|
||||
permission_slug,
|
||||
@@ -374,7 +429,8 @@ async fn save_editor_settings(
|
||||
}
|
||||
|
||||
let preferences = if wants_personal_update {
|
||||
let mut preferences = db::load_editor_preferences(&state.db, user.id, resource)
|
||||
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();
|
||||
if let Some(value) = payload.compact_view {
|
||||
@@ -430,7 +486,7 @@ async fn save_editor_settings(
|
||||
|
||||
db::save_editor_configuration(
|
||||
&state.db,
|
||||
user.id,
|
||||
user.as_ref().map(|value| value.id),
|
||||
resource,
|
||||
preferences.as_ref(),
|
||||
resource_settings
|
||||
@@ -583,12 +639,23 @@ pub async fn create_note(
|
||||
}
|
||||
|
||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||
let created_by = payload
|
||||
.created_by
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.chars().take(40).collect::<String>());
|
||||
let account_user = session_user(&state, &headers).await?;
|
||||
let created_by = account_user
|
||||
.as_ref()
|
||||
.map(|user| user.nickname.clone())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.created_by
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(40).collect::<String>())
|
||||
});
|
||||
let created_by_guest_id = if account_user.is_none() {
|
||||
requester_guest_id(&headers)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let note = db::create_note(
|
||||
&state.db,
|
||||
workspace.id,
|
||||
@@ -596,6 +663,7 @@ pub async fn create_note(
|
||||
title,
|
||||
payload.protect,
|
||||
created_by.as_deref(),
|
||||
created_by_guest_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok((
|
||||
@@ -725,9 +793,7 @@ pub async fn note_info(
|
||||
.await?;
|
||||
let resource_editor_settings =
|
||||
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
|
||||
let can_save_editor_settings = personal_editor_settings
|
||||
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
let can_manage_authorship = crate::auth::is_resource_owner(
|
||||
let workspace_owner = crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
@@ -735,6 +801,15 @@ pub async fn note_info(
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||
let password_write_access =
|
||||
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
let can_manage_authorship = workspace_owner || note_owner || password_write_access;
|
||||
let can_delete_files = can_manage_authorship;
|
||||
let can_upload_files = session_user(&state, &headers).await?.is_some()
|
||||
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
|
||||
if workspace.is_private == 0
|
||||
&& !db::note_public_page_disabled(&state.db, note.id).await?
|
||||
@@ -755,19 +830,8 @@ pub async fn note_info(
|
||||
private: workspace.is_private != 0,
|
||||
created_at: db::normalize_timestamp(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
can_delete_files: {
|
||||
let note_owner = session_user(&state, &headers)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|user| {
|
||||
note.created_by
|
||||
.as_deref()
|
||||
.map(|creator| creator == user.nickname)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
can_manage_authorship || note_owner
|
||||
},
|
||||
can_delete_files,
|
||||
can_upload_files,
|
||||
global_color,
|
||||
note_color,
|
||||
authorship_mode: resource_editor_settings.authorship_mode,
|
||||
@@ -797,6 +861,9 @@ 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?;
|
||||
save_editor_settings(
|
||||
&state,
|
||||
&headers,
|
||||
@@ -805,6 +872,7 @@ pub async fn set_note_editor_settings(
|
||||
"note",
|
||||
&format!("{workspace_slug}/{note_slug}"),
|
||||
db::EditorPreferenceResource::Note(note.id),
|
||||
creator_can_manage_authorship,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
@@ -935,6 +1003,23 @@ async fn anonymous_access_token_valid(
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn has_password_write_access(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
) -> Result<bool, ApiError> {
|
||||
for token in [
|
||||
crate::security::resource_token(headers, kind, slug),
|
||||
authorization_token(headers),
|
||||
] {
|
||||
if anonymous_access_token_valid(state, kind, slug, token).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn external_token_access_level(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
|
||||
+28
-6
@@ -34,6 +34,7 @@ pub struct PadInfo {
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
can_delete_files: bool,
|
||||
can_upload_files: bool,
|
||||
global_color: Option<String>,
|
||||
note_color: Option<String>,
|
||||
authorship_mode: String,
|
||||
@@ -64,11 +65,23 @@ pub async fn create_pad(
|
||||
));
|
||||
}
|
||||
let slug = unique_pad_slug(&state, &base).await?;
|
||||
let pad = db::create_pad(&state.db, &slug, title, password).await?;
|
||||
if let Some(user) = crate::auth::optional_user(&state, &headers)
|
||||
let account_user = crate::auth::optional_user(&state, &headers)
|
||||
.await
|
||||
.map_err(|e| ApiError::forbidden(&e.message))?
|
||||
{
|
||||
.map_err(|e| ApiError::forbidden(&e.message))?;
|
||||
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 {
|
||||
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
||||
.bind(user.id)
|
||||
.bind(&pad.slug)
|
||||
@@ -109,7 +122,7 @@ pub async fn pad_info(
|
||||
.await?;
|
||||
let resource_editor_settings =
|
||||
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
||||
let can_manage_authorship = crate::auth::is_resource_owner(
|
||||
let account_owner = crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
@@ -117,7 +130,12 @@ pub async fn pad_info(
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let can_save_editor_settings = personal_editor_settings
|
||||
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;
|
||||
let can_upload_files = session_user(&state, &headers).await?.is_some()
|
||||
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||
if pad.is_private == 0
|
||||
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
||||
@@ -136,6 +154,7 @@ pub async fn pad_info(
|
||||
created_at: db::normalize_timestamp(&pad.created_at),
|
||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||
can_delete_files: can_manage_authorship,
|
||||
can_upload_files,
|
||||
global_color,
|
||||
note_color,
|
||||
authorship_mode: resource_editor_settings.authorship_mode,
|
||||
@@ -162,6 +181,8 @@ pub async fn set_pad_editor_settings(
|
||||
let pad = db::find_pad(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
let creator_can_manage_authorship = pad_creator_is_requester(&headers, &pad)
|
||||
|| has_password_write_access(&state, &headers, "pad", &slug).await?;
|
||||
save_editor_settings(
|
||||
&state,
|
||||
&headers,
|
||||
@@ -170,6 +191,7 @@ pub async fn set_pad_editor_settings(
|
||||
"pad",
|
||||
&slug,
|
||||
db::EditorPreferenceResource::Pad(pad.id),
|
||||
creator_can_manage_authorship,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user