11.0.1 release: harden failure-report sample filenames and cap decompressed report size (#895)

* Harden failure-report sample filenames and cap decompressed report size

Fixes the two open security advisories.

GHSA-c284-w5m6-jhjm (path traversal, affects 9.0.6 through 11.0.0):
save_output() named each failure report's message sample after the
sample's Subject header, falling back to the raw subject whenever
sanitizing it produced an empty string. A subject of only path
separators and dots -- "../../../" or "/" -- sanitizes to "", so the raw
value reached os.path.join() and the .eml landed outside the samples
directory or at an absolute path. That subject comes from a message that
failed authentication, so any sender a monitored mailbox accepts
controls it. The name is now sanitized at write time and falls back to
"sample", and the caller-supplied filename_safe_subject key is no longer
trusted. get_filename_safe_string() also strips NUL (which would
otherwise make open() raise ValueError: embedded null byte and hold back
the whole mailbox batch), truncates before stripping trailing
characters, and strips trailing spaces along with trailing dots, since
Windows drops both when creating a file; its docstring now states the
guarantees callers depend on.

GHSA-43qf-f35w-2x4r (unbounded decompression, affects all versions
through 11.0.0): extract_report() inflated gzip with one unbounded
zlib.decompress() and read zip members with an unbounded .read(). The
attachment's content is chosen by its sender, and deflate reaches about
1000:1 on degenerate input, so a 100 KB attachment inflated to 100 MB
with a ~209 MiB peak. Extraction now stops at
MAX_DECOMPRESSED_REPORT_SIZE (100 MiB) and raises ParserError. The gzip
path moves to a zlib.decompressobj() bounded by max_length, which does
not raise on a stream that ends early the way the one-shot call did, so
the helper checks decompressor.eof itself; a stream with trailing bytes
after the gzip member still extracts, matching the old behavior. Both
behaviors were observed against the unmodified code first and are
pinned by tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Strip NUL from CSV fields so Python 3.10's csv writer accepts them

Python 3.10's csv writer raises _csv.Error: need to escape, but no
escapechar set on any field containing NUL (CPython issue 97503, a 3.10
regression fixed in 3.11+). Failure report text fields (subject, user
agent, authentication results, addresses, etc.) come from untrusted
mail, so a NUL byte in one made parsed_failure_reports_to_csv() -- and
therefore save_output() -- raise on 3.10, which the CLI's
except (OSError, ValueError) around save_output does not catch. NUL is
now stripped from every CSV field on all Python versions via a shared
_csv_safe() helper, applied in all three CSV writers, so output is
identical regardless of interpreter version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 11.0.1 release: bump version and finalize changelog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sean Whalen
2026-09-03 15:51:44 -04:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 823b0a1811
commit 09c88ca2a3
6 changed files with 465 additions and 18 deletions
+49 -1
View File
@@ -348,9 +348,57 @@ class Test(unittest.TestCase):
def testGetFilenameSafeStringNone(self):
"""get_filename_safe_string with None returns 'None'"""
result = parsedmarc.utils.get_filename_safe_string(None) # type: ignore[arg-type]
result = parsedmarc.utils.get_filename_safe_string(None)
self.assertEqual(result, "None")
def testGetFilenameSafeStringTraversalSequencesCollapseToEmpty(self):
"""A subject made only of path separators and dots sanitizes to the
empty string, so a caller's `or "sample"` fallback takes over rather
than a relative path reaching os.path.join()
Regression test for GHSA-c284-w5m6-jhjm: `../../../` and `/` are the
reporter's proof-of-concept subjects."""
for traversal in ("../../../", "/", "..", ".", "..\\..\\", "./../"):
with self.subTest(traversal=traversal):
self.assertEqual(
parsedmarc.utils.get_filename_safe_string(traversal), ""
)
def testGetFilenameSafeStringStripsNullByte(self):
"""A NUL byte is stripped, so the result cannot make open() raise
ValueError: embedded null byte"""
result = parsedmarc.utils.get_filename_safe_string("re\x00port")
self.assertEqual(result, "report")
def testGetFilenameSafeStringHasNoPathSeparators(self):
"""No os.sep or os.altsep survives a mixed-separator subject"""
result = parsedmarc.utils.get_filename_safe_string("a/b\\c:d..\\..\\e/../f")
self.assertEqual(result, "abcd....e..f")
self.assertNotIn(os.sep, result)
if os.altsep is not None:
self.assertNotIn(os.altsep, result)
def testGetFilenameSafeStringTruncatedNameHasNoTrailingDot(self):
"""Truncation happens before trailing dots are stripped, so a name
cut off just after a dot does not end in one
A trailing dot is silently dropped by Windows when the file is
created, which would make two distinct subjects collide on one
filename."""
subject = f"{'a' * 99}.{'b' * 50}"
result = parsedmarc.utils.get_filename_safe_string(subject)
self.assertEqual(result, "a" * 99)
self.assertFalse(result.endswith("."))
def testGetFilenameSafeStringStripsTrailingSpaces(self):
"""Trailing spaces are stripped like trailing dots, and mixed runs of
both, because Windows drops them when creating the file"""
for subject, expected in (("report ", "report"), ("report . .", "report")):
with self.subTest(subject=subject):
self.assertEqual(
parsedmarc.utils.get_filename_safe_string(subject), expected
)
def testGetFilenameSafeStringLong(self):
"""get_filename_safe_string truncates to 100 chars"""
result = parsedmarc.utils.get_filename_safe_string("a" * 200)