better filename check

linkliti committed Jan 24, 2026 at 21:54 UTC e5669daf6723b2dc91c1b9bc9abc57124ae608ed
9 files changed +75 -17
python/api/api_message.py
+3 -3
@@ -6,7 +6,7 @@ from python.helpers.api import ApiHandler, Request, Response
6 from python.helpers import files, projects
7 from python.helpers.print_style import PrintStyle
8 from python.helpers.projects import activate_project
9 -from werkzeug.utils import secure_filename
9 +from python.helpers.security import safe_filename
10 from initialize import initialize_agent
11 import threading
12
@@ -57,9 +57,9 @@ class ApiMessage(ApiHandler):
57 continue
58
59 try:
60 - filename = secure_filename(attachment["filename"])
60 + filename = safe_filename(attachment["filename"])
61 if not filename:
62 - continue
62 + raise ValueError("Invalid filename")
63
64 # Decode base64 content
65 file_content = base64.b64decode(attachment["base64"])
python/api/chat_files_path_get.py
-1
@@ -1,7 +1,6 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import files, memory, notification, projects, notification, runtime
3 import os
4 -from werkzeug.utils import secure_filename
4
5
6 class GetChatFilesPath(ApiHandler):
python/api/import_knowledge.py
+4 -2
@@ -1,7 +1,7 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import files, memory
3 import os
4 -from werkzeug.utils import secure_filename
4 +from python.helpers.security import safe_filename
5
6
7 class ImportKnowledge(ApiHandler):
@@ -32,7 +32,9 @@ class ImportKnowledge(ApiHandler):
32
33 for file in file_list:
34 if file and file.filename:
35 - filename = secure_filename(file.filename) # type: ignore
35 + filename = safe_filename(file.filename)
36 + if not filename:
37 + continue
38 file.save(os.path.join(KNOWLEDGE_FOLDER, filename))
39 saved_filenames.append(filename)
40
python/api/knowledge_reindex.py
-1
@@ -1,7 +1,6 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import files, memory, notification, projects, notification
3 import os
4 -from werkzeug.utils import secure_filename
4
5
6 class ReindexKnowledge(ApiHandler):
python/api/message.py
+4 -2
@@ -3,7 +3,7 @@ from python.helpers.api import ApiHandler, Request, Response
3
4 from python.helpers import files, extension
5 import os
6 -from werkzeug.utils import secure_filename
6 +from python.helpers.security import safe_filename
7 from python.helpers.defer import DeferredTask
8 from python.helpers.print_style import PrintStyle
9
@@ -37,7 +37,9 @@ class Message(ApiHandler):
37 for attachment in attachments:
38 if attachment.filename is None:
39 continue
40 - filename = secure_filename(attachment.filename)
40 + filename = safe_filename(attachment.filename)
41 + if not filename:
42 + continue
43 save_path = files.get_abs_path(upload_folder_ext, filename)
44 attachment.save(save_path)
45 attachment_paths.append(os.path.join(upload_folder_int, filename))
python/api/upload.py
+6 -2
@@ -1,6 +1,6 @@
1 from python.helpers.api import ApiHandler, Request, Response
2 from python.helpers import files
3 -from werkzeug.utils import secure_filename
3 +from python.helpers.security import safe_filename
4
5
6 class UploadFile(ApiHandler):
@@ -13,7 +13,11 @@ class UploadFile(ApiHandler):
13
14 for file in file_list:
15 if file and self.allowed_file(file.filename): # Check file type
16 - filename = secure_filename(file.filename) # type: ignore
16 + if not file.filename:
17 + continue
18 + filename = safe_filename(file.filename)
19 + if not filename:
20 + continue
21 file.save(files.get_abs_path("tmp/upload", filename))
22 saved_filenames.append(filename)
23
python/helpers/attachment_manager.py
+5 -4
@@ -3,7 +3,8 @@ import io
3 import base64
4 from PIL import Image
5 from typing import Dict, List, Optional, Tuple
6 -from werkzeug.utils import secure_filename
6 +from python.helpers.security import safe_filename
7 +from werkzeug.datastructures import FileStorage
8
9 from python.helpers.print_style import PrintStyle
10
@@ -41,10 +42,10 @@ class AttachmentManager:
42 except AttributeError:
43 return False
44
44 - def save_file(self, file, filename: str) -> Tuple[str, Dict]:
45 + def save_file(self, file: FileStorage, name: str) -> Tuple[str, Dict]:
46 """Save file and return path and metadata"""
47 try:
47 - filename = secure_filename(filename)
48 + filename = safe_filename(name)
49 if not filename:
50 raise ValueError("Invalid filename")
51
@@ -68,7 +69,7 @@ class AttachmentManager:
69 return file_path, metadata
70
71 except Exception as e:
71 - PrintStyle.error(f"Error saving file {filename}: {e}")
72 + PrintStyle.error(f"Error saving file {name}: {e}")
73 return None, {} # type: ignore
74
75 def generate_image_preview(self, image_path: str, max_size: int = 800) -> Optional[str]:
python/helpers/file_browser.py
+4 -2
@@ -4,7 +4,7 @@ import shutil
4 import base64
5 import subprocess
6 from typing import Dict, List, Tuple, Any
7 -from werkzeug.utils import secure_filename
7 +from python.helpers.security import safe_filename
8 from datetime import datetime
9
10 from python.helpers import files
@@ -69,7 +69,9 @@ class FileBrowser:
69 for file in files:
70 try:
71 if file and self._is_allowed_file(file.filename, file):
72 - filename = secure_filename(file.filename)
72 + filename = safe_filename(file.filename)
73 + if not filename:
74 + raise ValueError("Invalid filename")
75 file_path = target_dir / filename
76
77 file.save(str(file_path))
python/helpers/security.py new
+49
@@ -0,0 +1,49 @@
1 +import re
2 +import unicodedata
3 +from pathlib import Path
4 +from typing import Final, Optional
5 +
6 +# Forbidden characters:
7 +# Linux/Unix: / and NULL byte
8 +# Windows: < > : " / \ | ? * and ASCII control characters (0-31)
9 +# Shell-sensitive: ~ to prevent accidental home directory access
10 +FORBIDDEN_CHARS_RE: Final = re.compile(r'[<>:"|?*~/\\\x00-\x1f\x7f]')
11 +
12 +# Windows reserved filenames
13 +WINDOWS_RESERVED: Final = frozenset({
14 + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
15 + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
16 + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
17 +})
18 +
19 +FILENAME_MAX_LENGTH: Final = 255
20 +
21 +def safe_filename(filename: str) -> Optional[str]:
22 + # Normalize Unicode (NFC)
23 + filename = unicodedata.normalize("NFC", str(filename))
24 + # Replace forbidden chars
25 + filename = FORBIDDEN_CHARS_RE.sub("_", filename)
26 + # Remove leading/trailing spaces and trailing dots
27 + filename = filename.lstrip(" ").rstrip(". ")
28 +
29 + path = Path(filename)
30 + suffixes = ''.join(path.suffixes)
31 + stem = path.name[:-len(suffixes)] if suffixes else path.name
32 +
33 + # Check Windows reserved names
34 + if stem.upper() in WINDOWS_RESERVED:
35 + filename = f"{stem}-{suffixes}"
36 +
37 + # Truncate if too long
38 + if len(filename) > FILENAME_MAX_LENGTH:
39 + max_stem_len = FILENAME_MAX_LENGTH - len(suffixes)
40 + if max_stem_len > 0:
41 + # Truncate filename
42 + stem = stem[:max_stem_len]
43 + filename = stem + suffixes
44 + else:
45 + # Extension is too long, truncate everything
46 + filename = filename[:FILENAME_MAX_LENGTH]
47 + if not filename:
48 + return None
49 + return filename