github domainaware/parsedmarc 11.1.0

8 hours ago

Security

  • Aggregate reports containing a DOCTYPE declaration are now rejected, which fixes XML default-attribute amplification (GHSA-m47j-wp78-fqjg, affects 11.0.5 and earlier). Entity expansion was already disabled (xmltodict's disable_entities=True), but an internal DTD subset can also declare default attributes: <!ATTLIST z a0 CDATA "v" ... a999 CDATA "v"> followed by 10,000 <z/> elements makes expat report 1,000 attributes on each element, and xmltodict builds 10 million dict entries from 64 KB of XML. Locally that took 4.0 s and 1.4 GB of memory per report; the reporter measured 29 s and 1.4 GB from a 3.8 KB gzip email, and anyone who can send mail to a monitored mailbox can send it. Real aggregate reports never carry a DOCTYPE, so parse_aggregate_report_xml() now aborts with InvalidAggregateReport as soon as expat reaches one, before it reads the subset; the same document is rejected in under a millisecond. The lxml recovery path for malformed XML, which a document reaches when its first parse fails before the DOCTYPE (for example on a malformed XML declaration), applies the same check and now serializes only the root element, so no internal subset reaches the second parse. Reported by helixnebulatech.
  • A report email that cannot be re-serialized no longer aborts the whole mailbox batch (GHSA-qxwv-2vwj-v828, affects 11.0.5 and earlier). While walking the message, parse_report_email() serializes the first child of each part, which re-renders any message/rfc822 sub-message, and the Maildir backend re-serializes each message while fetching it, so a message/rfc822 part with a \x1c control character in a header (email.errors.HeaderWriteError) or multiparts nested about 250 levels deep (RecursionError; a shallower nesting is rejected as not a report) raised an exception that the mailbox loop's per-message except ParserError did not catch. The batch aborted before the message could be moved to the Invalid folder, so every later run, and every watch-mode check, aborted on the same message and no report behind it was ever processed. parse_report_email() now raises only ParserError subclasses for any input. A content error raised while fetching a message (email.errors.MessageError or RecursionError) is treated like a parse failure, so the message is moved to the Invalid folder (or deleted with delete_invalid); transport errors still propagate, so a dropped connection cannot get a legitimate report filed as invalid. A move or delete of an invalid message that the backend rejects is now logged and skipped, as it already was for archiving parsed reports, instead of aborting the batch: the Maildir backend moves a message by storing it again through the binary generator, which fails for a message nested that deeply on every Python and, on interpreters whose binary generator also verifies generated headers (e.g. some distribution builds; upstream CPython verifies them only when serializing to text), for the control-character one too. Such a message stays in the inbox, logged on each run, until it is removed by hand or delete_invalid is set. get_dmarc_reports_from_mbox() now reads each message's stored bytes instead of a re-serialized string, so these messages reach the parser and fail with a ParserError naming the message rather than an unrelated exception; an mbox run still stops at such a message, as it does at any message that raises a ParserError other than InvalidDMARCReport. Reported by helixnebulatech.
  • Failure report sample filenames are now limited by UTF-8 bytes as well as characters (GHSA-j53g-34vj-g25q, affects 11.0.5 and earlier). get_filename_safe_string() truncated the sample's Subject to 100 characters, but filesystems limit a name to 255 bytes (eCryptfs: 143), so a subject of 84 CJK characters or 63 emoji plus .eml made a 256-byte name. save_output() then failed with ENAMETOOLONG after the batch's JSON and CSV rows had already been appended, so a one-shot run retried the batch indefinitely and watch mode moved it, legitimate reports included, to the Unsaved folder, duplicating rows on each attempt; email_results() failed the same way. Names are now cut to at most 100 UTF-8 bytes without splitting a character, which leaves room for a (N) suffix and the extension under every common limit; ASCII names are unchanged. Reported by helixnebulatech.

Changes

  • CSV output now neutralizes spreadsheet formulas (CWE-1236, OWASP CSV Injection). Every text field in the aggregate, failure, and SMTP TLS CSV output comes from a report that a third party sent, and a cell beginning with =, +, -, or @ is evaluated as a formula by Excel, LibreOffice, and Google Sheets, so a reporter could set an org_name such as =HYPERLINK("http://evil","x") and have it run when an analyst opens the CSV. _csv_safe() now prefixes a string field that starts with =, +, -, @, a tab, or a carriage return with a single ', the OWASP-recommended fix, after the existing NUL strip; non-string values such as ASNs and counts are untouched. Compatibility: only cells starting with one of those six characters change, and real report fields almost never do. The JSON output and the data sent to the other output integrations are unchanged. (Closes #929)
  • Control characters in sender-controlled strings are escaped before they reach logs and error messages. An RFC 2047 encoded word in a Subject or From header can decode to a raw CR LF (=?utf-8?q?line1=0D=0AINJECTED?=), and parse_report_email() interpolated the decoded subject into its error messages and the From header into its Parsing mail from info line, so a sender could forge extra log lines in the mailbox watch loop's warnings. The new parsedmarc.utils.sanitize_for_log() replaces every non-printable character with its unicode_escape form (\r\n, \x1b, \u2028) rather than stripping it, so the log still shows that the characters were there; the ASCII space, letters in any script, and most emoji pass through, while non-ASCII spaces such as NBSP and U+3000 and format characters such as the zero-width joiner are escaped. It is applied to the subject and From header in parse_report_email(), the SMTP TLS policy-type in its "Invalid policy type" error, the RFC 9990 np, testing, and discovery_method values and the per-record parse error that the aggregate parser logs, and the org_name and report_id in the "Skipping duplicate aggregate report" debug lines in __init__.py and cli.py. (Closes #929)

Bug fixes

  • Pool workers now use the IPinfo API token and IP database the parent process selected, whatever the multiprocessing start method. parsedmarc.utils keeps the IPinfo Lite REST API token (_IPINFO_API_TOKEN, set by configure_ipinfo_api()) and the selected IP database path (_IP_DB_PATH, set by load_ip_db()) in process-global variables, and only the parent process sets them. Under the fork start method (the Linux default through Python 3.13) a pool worker inherits them ("All resources of the parent are inherited by the child process", Python multiprocessing docs), but under spawn (the macOS and Windows default) and forkserver (the Linux default from Python 3.14) it does not ("The child process will only inherit those resources necessary to run the process object's run() method"). A worker there started from scratch: its IPinfo API lookups were silently skipped (_ipinfo_api_lookup() returns None when no token is set), and _get_ip_database_path() fell back to the bundled MMDB instead of the freshly downloaded or cached copy the parent had selected, so enrichment (country, ASN) could differ from what the operator configured and from the same run under fork. The CLI parses file inputs through the process pool even with -n 1 (cli.py), so every file-based CLI run on such platforms was affected. parallel_map() now snapshots both globals in the parent and restores them in every worker via its pool initializer (which previously only reconstructed logging, and is renamed _init_worker accordingly), so workers make the same IPinfo API and MMDB selections the parent configured under every start method. The module-level psl_overrides list had a smaller gap: a fresh worker loads the bundled psl_overrides.txt at import time, and only switches to the operator's psl_overrides_path or URL when its first reverse DNS map load reloads the overrides from the config, so base-domain lookups made before that load (the first aggregate report's org_name and the first source IP's reverse DNS name) used the bundled list; the pool initializer now restores the parent's list in place as well. Reported by helixnebulatech. (Closes #928)

Don't miss a new parsedmarc release

NewReleases is sending notifications on new releases.