Skip to content

Commit 179decf

Browse files
committed
security: harden filename and socket payload handling
- sanitize SUBJECT-derived filenames in output_all_mail_as_file and reject path traversal via basename, separator stripping, and a commonpath check so writes cannot escape cwd - validate socket server JSON payloads (shape, size, command-name types) before dispatching to the executor, and guard recv against buffer overflow and undecodable bytes - route library-code diagnostics through mail_thunder_logger instead of print(), and correct docstring placement in the IMAP wrapper
1 parent 9c912cd commit 179decf

4 files changed

Lines changed: 79 additions & 19 deletions

File tree

je_mail_thunder/imap/imap_wrapper.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
import re
13
from email import message_from_bytes
24
from email import policy
35
from email.header import decode_header
@@ -93,13 +95,13 @@ def search_mailbox(self, search_str: [str, list] = "ALL", charset: str = None) -
9395

9496
def mail_content_list(
9597
self, search_str: [str, list] = "ALL", charset: str = None) -> List[Dict[str, Union[str, bytes]]]:
96-
mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}")
9798
"""
9899
Get all mail content as list
99100
:param search_str: Search pattern
100-
:param charset: Charset pattern
101+
:param charset: Charset pattern
101102
:return: All mail content as list [{"SUBJECT": "mail_subject", "FROM": "mail_from", "TO": "mail_to"}]
102103
"""
104+
mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}")
103105
try:
104106
mail_list = self.search_mailbox(search_str, charset)
105107
mail_content_dict = dict()
@@ -124,32 +126,54 @@ def mail_content_list(
124126
mail_thunder_logger.error(
125127
f"imap_mail_content_list, search_str: {search_str}, charset: {charset}, failed: {repr(error)}")
126128

129+
@staticmethod
130+
def _sanitize_subject_as_filename(subject) -> str:
131+
"""
132+
Derive a safe filename from a mail SUBJECT header.
133+
Strips directory components and any separator / traversal token.
134+
Falls back to "mail" when the sanitized result is empty.
135+
"""
136+
if subject is None:
137+
return "mail"
138+
name = os.path.basename(str(subject))
139+
name = name.replace("\x00", "")
140+
name = re.sub(r"[\\/\r\n\t]", "_", name)
141+
while ".." in name:
142+
name = name.replace("..", "_")
143+
name = name.strip(" .")
144+
return name if name else "mail"
145+
127146
def output_all_mail_as_file(
128147
self, search_str: [str, list] = "ALL", charset: str = None) -> List[Dict[str, Union[str, bytes]]]:
129-
mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}")
130148
"""
131149
Get all mail content data and output as file
132150
:param search_str: Search pattern
133-
:param charset: Charset pattern
151+
:param charset: Charset pattern
134152
:return: All mail content as list [{"SUBJECT": "mail_subject", "FROM": "mail_from", "TO": "mail_to"}]
135153
"""
154+
mail_thunder_logger.info(f"imap_output_all_mail_as_file, search_str: {search_str}, charset: {charset}")
136155
try:
137156
all_mail = self.mail_content_list(search_str=search_str, charset=charset)
138157
same_name_dict: Dict[str, int] = dict()
158+
cwd = os.path.abspath(os.getcwd())
139159
for mail in all_mail:
140-
if same_name_dict.get((mail.get("SUBJECT"))) is None:
141-
same_name_dict.update({mail.get("SUBJECT"): 0})
142-
else:
143-
same_name_dict.update({mail.get("SUBJECT"): same_name_dict.get(mail.get("SUBJECT")) + 1})
144-
with open(mail.get("SUBJECT") + str(same_name_dict.get(mail.get("SUBJECT"))), "w+") as file:
160+
safe_name = self._sanitize_subject_as_filename(mail.get("SUBJECT"))
161+
count = same_name_dict.get(safe_name, -1) + 1
162+
same_name_dict[safe_name] = count
163+
target_path = os.path.abspath(os.path.join(cwd, safe_name + str(count)))
164+
if os.path.commonpath([cwd, target_path]) != cwd:
165+
mail_thunder_logger.error(
166+
f"imap_output_all_mail_as_file, rejected path traversal: {target_path}")
167+
continue
168+
with open(target_path, "w+") as file:
145169
if isinstance(mail.get("BODY"), bytes):
146170
file.write(mail.get("BODY").decode("utf-8"))
147171
else:
148172
file.write(mail.get("BODY"))
149173
return all_mail
150174
except Exception as error:
151175
mail_thunder_logger.error(
152-
f"imap_mail_content_list, search_str: {search_str}, charset: {charset}, failed: {repr(error)}")
176+
f"imap_output_all_mail_as_file, search_str: {search_str}, charset: {charset}, failed: {repr(error)}")
153177

154178
def quit(self):
155179
"""

je_mail_thunder/utils/executor/action_executor.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ def execute_action(self, action_list: [list, dict]) -> dict:
8585
execute_record = "execute: " + str(action)
8686
execute_record_dict.update({execute_record: repr(error)})
8787
for key, value in execute_record_dict.items():
88-
print(key, flush=True)
89-
print(value, flush=True)
88+
mail_thunder_logger.info(f"{key} -> {value}")
9089
return execute_record_dict
9190

9291
def execute_files(self, execute_files_list: list) -> list:

je_mail_thunder/utils/package_manager/package_manager_class.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from importlib import import_module
22
from importlib.util import find_spec
33
from inspect import getmembers, isfunction, isbuiltin, isclass
4-
from sys import stderr
54

65
from je_mail_thunder.utils.logging.loggin_instance import mail_thunder_logger
76

@@ -27,7 +26,7 @@ def check_package(self, package: str):
2726
self.installed_package_dict.update(
2827
{found_spec.name: installed_package})
2928
except ModuleNotFoundError as error:
30-
print(repr(error), file=stderr)
29+
mail_thunder_logger.error(repr(error))
3130
return self.installed_package_dict.get(package, None)
3231

3332
def add_package_to_executor(self, package):
@@ -62,10 +61,10 @@ def get_member(self, package, predicate, target):
6261
target.event_dict.update(
6362
{str(package) + "_" + str(member[0]): member[1]})
6463
elif installed_package is None:
65-
print(repr(ModuleNotFoundError(f"Can't find package {package}")),
66-
file=stderr)
64+
mail_thunder_logger.error(
65+
repr(ModuleNotFoundError(f"Can't find package {package}")))
6766
else:
68-
print(f"Executor error {self.executor}", file=stderr)
67+
mail_thunder_logger.error(f"Executor error {self.executor}")
6968

7069
def add_package_to_target(self, package, target):
7170
"""
@@ -89,7 +88,7 @@ def add_package_to_target(self, package, target):
8988
target=target
9089
)
9190
except Exception as error:
92-
print(repr(error), file=stderr)
91+
mail_thunder_logger.error(repr(error))
9392

9493

9594
package_manager = PackageManager()

je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,48 @@
55

66
from je_mail_thunder.utils.executor.action_executor import execute_action
77

8+
MAX_PAYLOAD_BYTES = 8192
9+
MAX_ACTIONS = 256
10+
11+
12+
def _validate_payload(payload):
13+
"""
14+
Validate the decoded JSON payload structure before execution.
15+
Accepts either a list of action entries or a dict with an
16+
"auto_control" key mapping to such a list. Each action entry must
17+
be a non-empty list whose first element is a string command name.
18+
"""
19+
if isinstance(payload, dict):
20+
actions = payload.get("auto_control")
21+
if not isinstance(actions, list):
22+
raise ValueError("payload dict must contain 'auto_control' list")
23+
elif isinstance(payload, list):
24+
actions = payload
25+
else:
26+
raise ValueError("payload must be a dict or list")
27+
if len(actions) == 0:
28+
raise ValueError("action list is empty")
29+
if len(actions) > MAX_ACTIONS:
30+
raise ValueError(f"action list exceeds max length {MAX_ACTIONS}")
31+
for entry in actions:
32+
if not isinstance(entry, list) or len(entry) == 0 or len(entry) > 2:
33+
raise ValueError(f"invalid action entry: {entry!r}")
34+
if not isinstance(entry[0], str):
35+
raise ValueError(f"action command name must be str: {entry!r}")
36+
837

938
class TCPServerHandler(socketserver.BaseRequestHandler):
1039

1140
def handle(self):
12-
command_string = str(self.request.recv(8192).strip(), encoding="utf-8")
41+
raw = self.request.recv(MAX_PAYLOAD_BYTES).strip()
42+
if len(raw) >= MAX_PAYLOAD_BYTES:
43+
print("payload exceeds max buffer size; rejected", file=sys.stderr, flush=True)
44+
return
45+
try:
46+
command_string = str(raw, encoding="utf-8")
47+
except UnicodeDecodeError as error:
48+
print(repr(error), file=sys.stderr, flush=True)
49+
return
1350
socket = self.request
1451
print("command is: " + command_string, flush=True)
1552
if command_string == "quit_server":
@@ -19,6 +56,7 @@ def handle(self):
1956
else:
2057
try:
2158
execute_str = json.loads(command_string)
59+
_validate_payload(execute_str)
2260
for execute_function, execute_return in execute_action(execute_str).items():
2361
socket.sendto(str(execute_return).encode("utf-8"), self.client_address)
2462
socket.sendto("\n".encode("utf-8"), self.client_address)

0 commit comments

Comments
 (0)