Store Uploads Under Safe Paths
Users upload attachments, and the upload service stores each file under the name the client sent. The README has the rules, and uploads.py has the store. Build the path handling with the assistant and decide what to keep.
uploads.py1 of 3
Files
uploads.py
Python · Editableimport os
# Windows device names. Windows reserves them with any extension too.
RESERVED = {"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {f"LPT{i}" for i in range(1, 10)}
class UnsafeName(ValueError):
"""Raised for a client filename that can't be stored."""
def clean_name(filename):
"""Returns the name to store a client's filename under, or raises UnsafeName."""
if "\x00" in filename:
raise UnsafeName("filename contains a NUL byte")
name = filename
if name in ("", ".", ".."):
raise UnsafeName(f"no usable name in {filename!r}")
if name.startswith("."):
raise UnsafeName(f"hidden files are not allowed: {name!r}")
if is_reserved(name):
raise UnsafeName(f"reserved name: {name!r}")
if len(name) > 255:
raise UnsafeName("filename is longer than 255 characters")
return name
def is_reserved(name):
"""True for a Windows device name, in any case, with or without an extension."""
raise NotImplementedError
def is_inside(root, path):
"""True when path is root or inside it. Both paths are absolute and normalized."""
raise NotImplementedError
class UploadStore:
"""Keeps each user's uploads at <root>/<user_id>/<name>."""
def __init__(self, root):
self.root = os.path.abspath(root)
def path_for(self, user_id, filename):
"""The absolute path to store a client's filename at for user_id."""
raise NotImplementedError
import pytest
from uploads import UnsafeName, UploadStore, clean_name, is_inside
ROOT = "/srv/uploads"
def test_keeps_a_plain_name():
assert clean_name("cv.pdf") == "cv.pdf"
def test_drops_client_directories():
assert clean_name("photos/cat.png") == "cat.png"
assert clean_name("C:\\Users\\ana\\cv.pdf") == "cv.pdf"
def test_traversal_reduces_to_the_last_component():
assert clean_name("../../etc/passwd") == "passwd"
def test_nested_traversal_cannot_rebuild_dot_dot():
assert clean_name("docs/....//....//cv.pdf") == "cv.pdf"
def test_rejects_names_with_nothing_left():
for name in ["", ".", "..", "photos/", "photos/.."]:
with pytest.raises(UnsafeName):
clean_name(name)
def test_rejects_hidden_files():
for name in [".htaccess", "site/.htaccess"]:
with pytest.raises(UnsafeName):
clean_name(name)
def test_rejects_reserved_names():
for name in ["CON", "nul", "COM1.txt", "lpt9.tar.gz"]:
with pytest.raises(UnsafeName):
clean_name(name)
def test_allows_names_that_start_like_reserved_ones():
assert clean_name("console.log") == "console.log"
assert clean_name("Contract.pdf") == "Contract.pdf"
def test_limits_the_name_in_bytes():
assert clean_name("a" * 251 + ".txt") == "a" * 251 + ".txt"
with pytest.raises(UnsafeName):
clean_name("é" * 128 + ".txt")
def test_is_inside_compares_whole_components():
assert is_inside("/srv/uploads/42", "/srv/uploads/42/cv.pdf")
assert not is_inside("/srv/uploads/42", "/srv/uploads/420/cv.pdf")
assert not is_inside("/srv/uploads/42", "/srv/uploads")
def test_path_for_stores_under_the_user():
store = UploadStore(ROOT)
assert store.path_for(42, "cv.pdf") == "/srv/uploads/42/cv.pdf"
def test_path_for_never_leaves_the_user_directory():
store = UploadStore(ROOT)
assert store.path_for(42, "docs/....//....//cv.pdf") == "/srv/uploads/42/cv.pdf"
# Upload Paths
UploadStore (uploads.py) keeps each user's uploads at <root>/<user_id>/<name>. The name comes from the client, so treat it as hostile.
## What to Build
- clean_name keeps only the last path component of the client's name. Treat both / and \ as separators, since some browsers send a full Windows path.
- Reject what's left if it's empty, ".", "..", or hidden (it starts with a dot, like .htaccess).
- Uploads are mirrored to a Windows file share, so reject Windows device names (CON, PRN, AUX, NUL, COM1 to COM9, and LPT1 to LPT9) in any case, with or without an extension.
- Filesystems cap a name at 255 bytes. Measure the UTF-8 encoding.
- path_for returns an absolute path inside the user's own directory, and raises UnsafeName for anything that would land outside it.
All of this is path logic. The tests in test_uploads.py never touch the disk.
Test Output
No runs yet.