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:
+25
-3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user