mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-09-06 14:07:59 +00:00
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:
co-authored by
Claude Fable 5.1
parent
823b0a1811
commit
09c88ca2a3
+49
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user