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