fix(upload): sanitize multipart filename for folder uploads (#121)

Browsers send the full relative path (e.g. 'Screenshots/file.png') as
the multipart filename when uploading folders via webkitRelativePath.
The File entity rejects names containing '/' or '\', causing all files
in a folder upload to fail with 'Invalid file name'.

Three fixes:
- Backend: strip path components from multipart filename in file_handler,
  keeping only the basename. Also prevents path-traversal attacks.
- Frontend (fileOperations.js): explicitly pass file.name as the third
  argument to FormData.append() in uploadFolderFiles() to override the
  browser's relative path.
- Frontend (ui.js): detect folder drops in drag-and-drop handlers by
  checking webkitRelativePath, and route them to uploadFolderFiles()
  instead of uploadFiles() so subfolders are created first.

Closes #121
This commit is contained in:
Dionisio
2026-02-16 17:58:50 +01:00
parent f70890e884
commit a4709426d9
4 changed files with 35 additions and 5 deletions
Generated
+1 -1
View File
@@ -1750,7 +1750,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "oxicloud" name = "oxicloud"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
+13 -1
View File
@@ -64,7 +64,19 @@ impl FileHandler {
} }
if name == "file" { if name == "file" {
let filename = field.file_name().unwrap_or("unnamed").to_string(); let raw_filename = field.file_name().unwrap_or("unnamed").to_string();
// Browsers send the full relative path (e.g. "Screenshots/file.png")
// as the filename for folder uploads via webkitRelativePath.
// Strip path components to get the basename only.
// This also prevents path-traversal attacks.
let filename = raw_filename
.rsplit('/')
.next()
.unwrap_or(&raw_filename)
.rsplit('\\')
.next()
.unwrap_or(&raw_filename)
.to_string();
let content_type = field let content_type = field
.content_type() .content_type()
.unwrap_or("application/octet-stream") .unwrap_or("application/octet-stream")
+3 -1
View File
@@ -279,7 +279,9 @@ const fileOps = {
const formData = new FormData(); const formData = new FormData();
formData.append('folder_id', targetFolderId); formData.append('folder_id', targetFolderId);
formData.append('file', file); // Use file.name as the explicit filename to prevent the browser
// from sending the full webkitRelativePath as the filename
formData.append('file', file, file.name);
const displayName = file.webkitRelativePath || file.name; const displayName = file.webkitRelativePath || file.name;
+18 -2
View File
@@ -291,7 +291,15 @@ const ui = {
e._oxiHandled = true; // Mark as handled for document-level fallback e._oxiHandled = true; // Mark as handled for document-level fallback
dropzone.classList.remove('active'); dropzone.classList.remove('active');
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
fileOps.uploadFiles(e.dataTransfer.files); // Detect folder drops: files from folder drops have webkitRelativePath set
const hasRelativePaths = Array.from(e.dataTransfer.files).some(
f => f.webkitRelativePath && f.webkitRelativePath.includes('/')
);
if (hasRelativePaths) {
fileOps.uploadFolderFiles(e.dataTransfer.files);
} else {
fileOps.uploadFiles(e.dataTransfer.files);
}
} }
setTimeout(() => { setTimeout(() => {
dropzone.style.display = 'none'; dropzone.style.display = 'none';
@@ -327,7 +335,15 @@ const ui = {
if (e._oxiHandled) return; if (e._oxiHandled) return;
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
fileOps.uploadFiles(e.dataTransfer.files); // Detect folder drops: files from folder drops have webkitRelativePath set
const hasRelativePaths = Array.from(e.dataTransfer.files).some(
f => f.webkitRelativePath && f.webkitRelativePath.includes('/')
);
if (hasRelativePaths) {
fileOps.uploadFolderFiles(e.dataTransfer.files);
} else {
fileOps.uploadFiles(e.dataTransfer.files);
}
} }
setTimeout(() => { setTimeout(() => {