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:
@@ -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, ""'''
|
||||
Reference in New Issue
Block a user