fix(upload): a NUL or an over-long name never reaches open()

safe_upload_name let two names through that the filesystem cannot hold,
and each raised at open(): a 500 with the booth torn down. A NUL raised
ValueError, and a 200-character cap let 200 two-byte characters overrun
NAME_MAX (255 bytes, ENAMETOOLONG). The NUL is now removed first, so it
cannot shield a leading dot from the hide rule. The cap is 200 UTF-8
bytes, cut on a character boundary, and it comes out of the stem: the
extension is what classify reads, so a name that used to fit (80 CJK
characters) keeps its kind.

The NUL test posts a raw multipart body: httpx percent-escapes a NUL in
files=, so the server would see a literal %00 and the test would prove
nothing. Falsifiers in tests/mutations/upload_names.toml, 4/4 proved.
Found by design-dev's r3 heid bug hunt (hulda).
This commit is contained in:
vh
2026-09-24 16:54:30 -07:00
parent d54bb04414
commit 92c774e105
3 changed files with 121 additions and 3 deletions
+25 -3
View File
@@ -982,11 +982,33 @@ def _form_text(form, key: str) -> str:
return value if isinstance(value, str) else ""
UPLOAD_NAME_MAX_BYTES = 200 # NAME_MAX is 255 bytes; the rest is _dedupe_name's room
def safe_upload_name(name: str, fallback: str) -> str:
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
base = (name or "").replace("\\", "/").split("/")[-1].strip()
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)
that the filesystem can actually hold.
Two names used to reach `open()` and raise, a 500 with the booth torn down
(r3 heid bug hunt, hulda): a NUL, the one byte no POSIX name can hold
(ValueError), and a name over NAME_MAX, which is 255 BYTES — a 200-character
cap let 200 two-byte characters through (ENAMETOOLONG). The NUL goes FIRST,
so it cannot shield a leading dot from the hide rule. The cap is 200 UTF-8
bytes, leaving room for `_dedupe_name`'s suffix, and it comes out of the
STEM: the extension is what `classify` reads, so a cut `.png` would no
longer be an image. A cut through a multibyte character drops the partial
character, and a lone surrogate, which no filename can encode either, goes
the same way.
"""
base = (name or "").replace("\x00", "").replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".") # a leading dot would hide the file from every listing
return base[:200] or fallback
stem, dot, ext = base.rpartition(".")
tail = dot + ext if stem and len((dot + ext).encode("utf-8", "surrogatepass")) <= 16 else ""
head = stem if tail else base
room = UPLOAD_NAME_MAX_BYTES - len(tail.encode("utf-8", "surrogatepass"))
base = (head.encode("utf-8", "surrogatepass")[:room]
+ tail.encode("utf-8", "surrogatepass")).decode("utf-8", "ignore")
return base or fallback
def _dedupe_name(name: str, used: set) -> str: