From 320bde146346baf7ccefffa4bb8ca87aaf8b959a Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 17 May 2026 18:08:57 -0300 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20SECURITY-#29:=20Saniti?= =?UTF-8?q?ze=20attachment=20filenames=20against=20path=20traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- email_profile/parser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/email_profile/parser.py b/email_profile/parser.py index b86fdad..cc43bdb 100644 --- a/email_profile/parser.py +++ b/email_profile/parser.py @@ -213,11 +213,15 @@ def parse_rfc822(raw_message: bytes) -> ParsedBody: if not filename: continue + safe_name = Path(_decode_header(filename).replace("\\", "/")).name + if not safe_name or safe_name in (".", ".."): + continue + payload = part.get_payload(decode=True) or b"" body.attachments.append( Attachment( - file_name=_decode_header(filename), + file_name=safe_name, content_type=content_type, content=( payload From 05e53cc417bcb50c2441a58e4b682e1af67df15f Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 17 May 2026 18:09:03 -0300 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#29:=20Add=20path?= =?UTF-8?q?=20traversal=20tests=20for=20attachment=20filenames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_parser.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_parser.py b/tests/test_parser.py index fbc6163..bc175d0 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -42,6 +42,32 @@ def test_content(self): ) +class TestAttachmentPathTraversal(TestCase): + def _msg(self, filename: str) -> bytes: + return ( + b"From: a@x\r\nTo: b@x\r\nSubject: t\r\n" + b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n' + b"--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" + b"--B\r\nContent-Disposition: attachment; " + b'filename="' + filename.encode() + b'"\r\n\r\n' + b"payload\r\n--B--\r\n" + ) + + def test_unix_traversal_stripped(self): + atts = parse_rfc822(self._msg("../../etc/passwd")).attachments + self.assertEqual(len(atts), 1) + self.assertEqual(atts[0].file_name, "passwd") + + def test_windows_traversal_stripped(self): + atts = parse_rfc822( + self._msg("..\\\\..\\\\windows\\\\evil.exe") + ).attachments + self.assertEqual(atts[0].file_name, "evil.exe") + + def test_pure_traversal_dropped(self): + self.assertEqual(parse_rfc822(self._msg("../..")).attachments, []) + + class TestNamedHeaders(TestCase): def test_named_headers_extracted(self): raw = (