security upgrade

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 09:57:43 +02:00
parent 2274cf57c9
commit f6b8e016a8
24 changed files with 1100 additions and 272 deletions
+77 -12
View File
@@ -16,6 +16,7 @@ pub async fn upload_pad_file(
Path(slug): Path<String>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, ApiError> {
require_authenticated_upload(&state, &headers).await?;
let mut password: Option<String> = None;
let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
@@ -55,8 +56,9 @@ pub async fn upload_pad_file(
&state,
&slug,
password.as_deref(),
access_token.as_deref(),
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_pad_password(&pad, password.as_deref())
@@ -68,7 +70,7 @@ pub async fn upload_pad_file(
&state,
"pad",
&slug,
access_token.as_deref(),
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
bearer_token(&headers),
)
.await?
@@ -144,8 +146,9 @@ pub async fn pad_files(
&state,
&slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
@@ -175,8 +178,9 @@ pub async fn delete_pad_file(
&state,
&slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
@@ -201,6 +205,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_authenticated_upload(&state, &headers).await?;
let mut password: Option<String> = None;
let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
@@ -241,8 +246,9 @@ pub async fn upload_note_file(
&workspace_slug,
&note_slug,
password.as_deref(),
access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
@@ -255,7 +261,7 @@ pub async fn upload_note_file(
&state,
"workspace",
&workspace_slug,
access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
bearer_token(&headers),
)
.await?
@@ -309,8 +315,9 @@ pub async fn delete_note(
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
@@ -323,7 +330,7 @@ pub async fn delete_note(
&state,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
)
.await?
@@ -360,8 +367,9 @@ pub async fn note_files(
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
@@ -392,8 +400,9 @@ pub async fn delete_note_file(
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let workspace_owner = crate::auth::is_resource_owner(
@@ -429,6 +438,19 @@ pub async fn delete_note_file(
Ok(Json(serde_json::json!({"ok": true})))
}
async fn require_authenticated_upload(
state: &SharedState,
headers: &HeaderMap,
) -> Result<(), ApiError> {
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
if user.is_none() {
return Err(ApiError::forbidden("Log in to upload files."));
}
Ok(())
}
pub async fn download_file(
State(state): State<SharedState>,
Path((token, filename)): Path<(String, String)>,
@@ -476,13 +498,29 @@ async fn serve_token_file(
.get_local_with_legacy(&key, &legacy_key)
.await
.map_err(|_| ApiError::not_found_file())?;
let mime = mime_guess::from_path(&safe).first_or_octet_stream();
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
let inline_image = is_safe_inline_image_mime(guessed_mime.essence_str());
let served_mime = if inline_image {
guessed_mime.as_ref()
} else {
"application/octet-stream"
};
let disposition = if inline_image { "inline" } else { "attachment" };
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
HeaderValue::from_str(served_mime)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
.expect("sanitized attachment filename"),
);
response.headers_mut().insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("default-src 'none'; sandbox"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
@@ -501,6 +539,19 @@ async fn serve_token_file(
Ok(response)
}
fn is_safe_inline_image_mime(value: &str) -> bool {
matches!(
value,
"image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "image/avif"
| "image/bmp"
| "image/x-icon"
)
}
fn sanitize_filename(value: &str) -> String {
let name = std::path::Path::new(value)
.file_name()
@@ -522,3 +573,17 @@ fn sanitize_filename(value: &str) -> String {
clean.chars().take(160).collect()
}
}
#[cfg(test)]
mod tests {
use super::is_safe_inline_image_mime;
#[test]
fn only_raster_images_are_inline() {
assert!(is_safe_inline_image_mime("image/png"));
assert!(is_safe_inline_image_mime("image/jpeg"));
assert!(!is_safe_inline_image_mime("image/svg+xml"));
assert!(!is_safe_inline_image_mime("text/html"));
assert!(!is_safe_inline_image_mime("application/xml"));
}
}