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
+45
View File
@@ -0,0 +1,45 @@
# Upload filenames the filesystem cannot hold. Two reached open() and raised,
# a 500 with the booth torn down (r3 heid bug hunt, hulda, 2026-09-24): a NUL,
# and a name over NAME_MAX (255 BYTES) that a 200-CHARACTER cap let through.
# Every row is a change tests/test_booth.py claims to forbid.
unit = "upload names the filesystem can hold"
[[mutation]]
label = "a NUL in an upload name reaches open() (ValueError, a 500)"
file = "booth/app.py"
test = "tests/test_booth.py::test_upload_a_nul_in_a_filename_never_500s"
old = '''
base = (name or "").replace("\x00", "").replace("\\", "/").split("/")[-1].strip()'''
new = '''
base = (name or "").replace("\\", "/").split("/")[-1].strip()'''
[[mutation]]
label = "the NUL is stripped after the dot rule (a NUL shields a leading dot)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_drops_nul_before_the_dot_rule"
old = '''
base = (name or "").replace("\x00", "").replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".") # a leading dot would hide the file from every listing'''
new = '''
base = (name or "").replace("\\", "/").split("/")[-1].strip()
base = base.lstrip(".").replace("\x00", "") # a leading dot would hide the file from every listing'''
[[mutation]]
label = "the cap counts characters, not bytes (ENAMETOOLONG, a 500)"
file = "booth/app.py"
test = "tests/test_booth.py::test_upload_a_name_over_name_max_in_bytes_never_500s"
old = '''
base = (head.encode("utf-8", "surrogatepass")[:room]
+ tail.encode("utf-8", "surrogatepass")).decode("utf-8", "ignore")'''
new = '''
base = base[:200]'''
[[mutation]]
label = "the cut comes out of the whole name (a long .png stops being an image)"
file = "booth/app.py"
test = "tests/test_booth.py::test_safe_upload_name_keeps_the_extension_through_the_cut"
old = '''
head = stem if tail else base'''
new = '''
head, tail = base, ""'''