Coverage for src/local_deep_research/security/filename_sanitizer.py: 99%
43 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-02 13:53 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-02 13:53 +0000
1"""Filename sanitization for file uploads.
3Wraps werkzeug's secure_filename with additional safety checks.
4All file upload endpoints should use sanitize_filename() instead of
5importing secure_filename directly.
6"""
8from __future__ import annotations
10import hashlib
11from typing import Optional
13from werkzeug.utils import secure_filename
15# Maximum filename length (including extension)
16MAX_FILENAME_LENGTH = 255
19class UnsafeFilenameError(ValueError):
20 """Raised when a filename cannot be sanitized to a safe value."""
23def sanitize_filename(
24 filename: Optional[str],
25 *,
26 allowed_extensions: Optional[set[str]] = None,
27 max_length: int = MAX_FILENAME_LENGTH,
28) -> str:
29 """Sanitize an uploaded filename for safe filesystem storage.
31 Args:
32 filename: Raw filename from the upload.
33 allowed_extensions: Optional set of allowed extensions
34 (lowercase, with dot, e.g. {".pdf", ".txt"}).
35 If None, all extensions are allowed.
36 max_length: Maximum allowed filename length.
38 Returns:
39 Sanitized filename safe for filesystem use. If the stem (the
40 part before the last dot) sanitizes to nothing but still
41 contains letters or digits — as happens for non-Latin scripts
42 such as CJK or Cyrillic, which werkzeug's secure_filename()
43 drops entirely — the stem is replaced with a deterministic
44 ``upload-<12 hex chars>`` name derived from a hash of the
45 original filename, and the sanitized extension is preserved.
47 Raises:
48 UnsafeFilenameError: If the filename is empty, becomes empty
49 after sanitization, has a disallowed extension,
50 ``max_length`` is not positive, or the extension alone
51 exceeds ``max_length`` (leaving no room for a stem).
52 """
53 if not filename:
54 raise UnsafeFilenameError("No filename provided")
55 if max_length < 1:
56 raise UnsafeFilenameError("Maximum filename length must be positive")
58 # Strip null bytes before passing to secure_filename
59 cleaned = filename.replace("\x00", "")
61 # Apply werkzeug's path traversal protection
62 safe_name = secure_filename(cleaned)
64 raw_stem, separator, raw_extension = cleaned.rpartition(".")
65 if not separator:
66 raw_stem = cleaned
68 if not secure_filename(raw_stem) and any(
69 character.isalnum() for character in raw_stem
70 ):
71 extension = ""
72 if separator: 72 ↛ 80line 72 didn't jump to line 80 because the condition on line 72 was always true
73 extension_source = secure_filename(f"x.{raw_extension}")
74 extension_index = extension_source.rfind(".")
75 if extension_index > 0:
76 extension = extension_source[extension_index:]
78 # A surrogate can occur in a filesystem-derived or decoded name.
79 # It only contributes to the digest; the generated name stays ASCII.
80 digest = hashlib.sha256(
81 cleaned.encode("utf-8", errors="surrogatepass")
82 ).hexdigest()[:12]
83 safe_name = f"upload-{digest}{extension}"
85 if not safe_name:
86 raise UnsafeFilenameError(
87 "Filename contains no safe characters after sanitization"
88 )
90 # Enforce length limit
91 if len(safe_name) > max_length:
92 # Preserve extension when truncating
93 dot_idx = safe_name.rfind(".")
94 if dot_idx > 0:
95 ext = safe_name[dot_idx:]
96 stem_length = max_length - len(ext)
97 if stem_length < 1:
98 raise UnsafeFilenameError(
99 "Filename extension exceeds maximum length"
100 )
101 safe_name = safe_name[:stem_length] + ext
102 else:
103 safe_name = safe_name[:max_length]
105 # Validate extension if allowlist provided
106 if allowed_extensions is not None:
107 dot_idx = safe_name.rfind(".")
108 ext = safe_name[dot_idx:].lower() if dot_idx > 0 else ""
109 # Normalize allowlist for case-insensitive comparison
110 normalized = {e.lower() for e in allowed_extensions}
111 if ext not in normalized:
112 raise UnsafeFilenameError("File type not allowed")
114 return safe_name