commit a780d21a926a1f2bf7d0e47680ee5ff5f3d9d616
parent 62321f14a3aade5cb5fbb73a417b753296b9a918
Author: claude-bot <noreply@anthropic.com>
Date: Mon, 5 Oct 2026 14:19:11 +0000
Accept any filename on upload: sanitize server-side, stop using filename in temp path
- Add sanitize_filename(): drops directory parts, folds accents, replaces unsafe chars with _
- /api/put and /api/put-url use /tmp/<uuid> only (fixes path traversal via filename / Content-Disposition)
- Remove frontend filename rejection and update docs
Diffstat:
4 files changed, 26 insertions(+), 12 deletions(-)
diff --git a/app/main.py b/app/main.py
@@ -280,7 +280,7 @@ async def handle_upload(
results = []
for file in files:
- temp_path = f"/tmp/{uuid.uuid4()}-{file.filename}"
+ temp_path = f"/tmp/{uuid.uuid4()}"
with open(temp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
@@ -522,7 +522,7 @@ async def handle_upload_from_url(
or f"download-{uuid.uuid4().hex[:8]}"
)
- temp_path = f"/tmp/{uuid.uuid4()}-{filename}"
+ temp_path = f"/tmp/{uuid.uuid4()}"
downloaded = 0
async with httpx.AsyncClient(
@@ -549,7 +549,6 @@ async def handle_upload_from_url(
if "filename=" in cd:
filename = cd.split("filename=")[-1].strip('"; ') or filename
- temp_path = f"/tmp/{uuid.uuid4()}-{filename}"
with open(temp_path, "wb") as f:
async for chunk in r.aiter_bytes():
diff --git a/app/storage.py b/app/storage.py
@@ -1,6 +1,7 @@
import os
import re
import time
+import unicodedata
import uuid
import json
import tempfile
@@ -296,9 +297,30 @@ def _do_upload_bytes(data: bytes, hf_path: str):
add=[(data, hf_path)],
)
+_UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
+
+
+def sanitize_filename(filename: str, max_len: int = 100) -> str:
+ """Turn any user-supplied filename into a safe object-key segment.
+
+ Accepts spaces, accents, symbols, emoji and path fragments: directory parts
+ are dropped, accents are folded to ASCII, and anything else unsafe becomes
+ a single underscore. The extension is kept.
+ """
+ name = (filename or "").replace("\\", "/").split("/")[-1]
+ name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
+ stem, dot, ext = name.rpartition(".")
+ if not dot:
+ stem, ext = name, ""
+ stem = _UNSAFE_NAME_CHARS.sub("_", stem).strip("._") or "file"
+ ext = _UNSAFE_NAME_CHARS.sub("", ext)[:10]
+ stem = stem[: max_len - len(ext) - 1]
+ return f"{stem}.{ext}" if ext else stem
+
+
async def upload_temp_file(temp_path: str, filename: str, folder: str = "uploads") -> str:
slug = str(uuid.uuid4())[:8]
- safe_filename = filename.replace(" ", "_")
+ safe_filename = sanitize_filename(filename)
hf_path = f"{folder}/{slug}-{safe_filename}"
await asyncio.to_thread(_do_upload, temp_path, hf_path)
diff --git a/app/templates/documentation.html b/app/templates/documentation.html
@@ -179,7 +179,7 @@
<h3>How it works</h3>
<p>Each file is streamed to a local temp path, checked against the rate limiter (50 MB/minute, 300 MB/hour
per client IP), then uploaded to Hugging Face at <code><folder>/<8-char-uuid>-<original-filename></code>
- (spaces in the filename become underscores). The random prefix means two uploads can never collide, and the
+ (any characters other than letters, digits, <code>.</code>, <code>_</code> and <code>-</code> become underscores, and accents are simplified). The random prefix means two uploads can never collide, and the
filename portion is still readable. If 2 or more files were uploaded together, a <em>batch</em> is also created
(see <a href="#batches">Batches</a> below) so the whole set can be shared/downloaded as one link.</p>
<h3>Response <code>200</code></h3>
diff --git a/app/templates/index.html b/app/templates/index.html
@@ -1134,14 +1134,10 @@
const allFiles = Array.from(fileList);
const files = [];
const rejectedSize = [];
- const rejectedName = [];
- const allowedNameRegex = /^[a-zA-Z0-9_.\-]+$/;
allFiles.forEach(f => {
if (f.size > MAX_FILE_SIZE_BYTES) {
rejectedSize.push(f.name);
- } else if (!allowedNameRegex.test(f.name)) {
- rejectedName.push(f.name);
} else {
files.push(f);
}
@@ -1151,9 +1147,6 @@
if (rejectedSize.length) {
errorMessages.push(`Skipped ${rejectedSize.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejectedSize.join(', ')}`);
}
- if (rejectedName.length) {
- errorMessages.push(`Skipped ${rejectedName.length} file(s) with invalid characters (only a-z, A-Z, 0-9, _, -, . allowed): ${rejectedName.join(', ')}`);
- }
if (errorMessages.length) {
showError(errorMessages.join(' | '));