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, soparse_aggregate_report_xml()now aborts withInvalidAggregateReportas 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 anymessage/rfc822sub-message, and the Maildir backend re-serializes each message while fetching it, so amessage/rfc822part with a\x1ccontrol 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-messageexcept ParserErrordid not catch. The batch aborted before the message could be moved to theInvalidfolder, 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 onlyParserErrorsubclasses for any input. A content error raised while fetching a message (email.errors.MessageErrororRecursionError) is treated like a parse failure, so the message is moved to theInvalidfolder (or deleted withdelete_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 ordelete_invalidis 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 aParserErrornaming the message rather than an unrelated exception; an mbox run still stops at such a message, as it does at any message that raises aParserErrorother thanInvalidDMARCReport. 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'sSubjectto 100 characters, but filesystems limit a name to 255 bytes (eCryptfs: 143), so a subject of 84 CJK characters or 63 emoji plus.emlmade a 256-byte name.save_output()then failed withENAMETOOLONGafter 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 theUnsavedfolder, 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 anorg_namesuch 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
SubjectorFromheader can decode to a raw CR LF (=?utf-8?q?line1=0D=0AINJECTED?=), andparse_report_email()interpolated the decoded subject into its error messages and theFromheader into itsParsing mail frominfo line, so a sender could forge extra log lines in the mailbox watch loop's warnings. The newparsedmarc.utils.sanitize_for_log()replaces every non-printable character with itsunicode_escapeform (\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 andFromheader inparse_report_email(), the SMTP TLSpolicy-typein its "Invalid policy type" error, the RFC 9990np,testing, anddiscovery_methodvalues and the per-record parse error that the aggregate parser logs, and theorg_nameandreport_idin the "Skipping duplicate aggregate report" debug lines in__init__.pyandcli.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.utilskeeps the IPinfo Lite REST API token (_IPINFO_API_TOKEN, set byconfigure_ipinfo_api()) and the selected IP database path (_IP_DB_PATH, set byload_ip_db()) in process-global variables, and only the parent process sets them. Under theforkstart 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 underspawn(the macOS and Windows default) andforkserver(the Linux default from Python 3.14) it does not ("The child process will only inherit those resources necessary to run the process object'srun()method"). A worker there started from scratch: its IPinfo API lookups were silently skipped (_ipinfo_api_lookup()returnsNonewhen 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 underfork. 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_workeraccordingly), so workers make the same IPinfo API and MMDB selections the parent configured under every start method. The module-levelpsl_overrideslist had a smaller gap: a fresh worker loads the bundledpsl_overrides.txtat import time, and only switches to the operator'spsl_overrides_pathor 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'sorg_nameand 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)