Sanitize Uploaded Filenames

Turn untrusted filenames into safe basenames by stripping paths, blocking reserved names, normalizing Unicode, and limiting length.

PythonSecurity Boundaries

Code

snippet
from __future__ import annotations

import re
import unicodedata

_INVALID = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_WHITESPACE = re.compile(r"\s+")
_RESERVED = {
    "CON",
    "PRN",
    "AUX",
    "NUL",
    *(f"COM{index}" for index in range(1, 10)),
    *(f"LPT{index}" for index in range(1, 10)),
}


def safe_filename(
    name: str,
    *,
    max_length: int = 120,
    fallback: str = "file",
) -> str:
    if not isinstance(name, str):
        raise TypeError("name must be a string")
    if (
        not isinstance(max_length, int)
        or isinstance(max_length, bool)
        or not 8 <= max_length <= 255
    ):
        raise ValueError("max_length must be between 8 and 255")
    if not isinstance(fallback, str) or not fallback:
        raise ValueError("fallback must be a non-empty string")

    normalized = unicodedata.normalize("NFKC", name)
    normalized = normalized.replace("\\", "/").rsplit("/", 1)[-1]
    normalized = "".join(
        character
        for character in normalized
        if unicodedata.category(character)[0] != "C"
    )
    base = _INVALID.sub("_", normalized)
    base = _WHITESPACE.sub(" ", base).strip(" .")
    if not base:
        return fallback[:max_length]

    stem = base.split(".", 1)[0].strip().upper()
    if stem in _RESERVED:
        base = f"_{base}"

    if len(base) > max_length:
        head, dot, suffix = base.rpartition(".")
        candidate = (
            f".{suffix}"
            if dot
            and 0 < len(suffix) <= 16
            and suffix.isalnum()
            else ""
        )
        keep = max(1, max_length - len(candidate))
        base = f"{head[:keep].rstrip(' .')}{candidate}"

    return base or fallback[:max_length]


def main() -> None:
    samples = [
        "../../etc/passwd",
        "C:\\Windows\\System32\\cmd.exe",
        "CON.txt",
        "  ..hidden..  ",
        "résumé 2026.pdf",
        "a" * 200 + ".tar.gz",
        "report\x00final.pdf",
    ]
    for sample in samples:
        print(repr(sample), "->", safe_filename(sample))

    try:
        safe_filename("ok.txt", max_length=2)
    except ValueError as error:
        print(error)


if __name__ == "__main__":
    main()