From 2986c58b0f06e6b759c1a52b8d16eafc9cefdafe Mon Sep 17 00:00:00 2001 From: Sean Whalen <44679+seanthegeek@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:32:04 -0400 Subject: [PATCH] Redact httpx2 URLs whenever httpx2 is installed (#939) _HTTP_URL_CLASSES took httpx2 from dns.query, so it only matched httpx2.URL when dnspython 2.9+ had imported httpx2. dnspython 2.9 requires Python >= 3.11, so on Python 3.10 CI gets dnspython 2.8 while httpx2 is still installed (hatch in the [build] extra requires it), and httpx2.URL arguments on the httpx2 logger went unmasked. Three TestHttpx2LogRedaction tests failed on 3.10 as a result. httpx2 is now imported directly behind a ModuleNotFoundError guard. The new tests re-execute cli.py with dns.query.httpx2 removed and with httpx2 unimportable, so the regression is caught on every dnspython version. Also drops the import of dns.query, which nothing uses now. Follow-up to GHSA-hjr3-rx4c-24cv (unreleased). Co-authored-by: Claude Opus 5.5 --- parsedmarc/cli.py | 29 ++++++++++++++++------ tests/test_cli.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/parsedmarc/cli.py b/parsedmarc/cli.py index 2aab7c7e..24873f7e 100644 --- a/parsedmarc/cli.py +++ b/parsedmarc/cli.py @@ -20,7 +20,6 @@ from ssl import CERT_NONE, create_default_context from typing import TYPE_CHECKING, Any import dns.exception -import dns.query import dns.resolver import dns.reversename import httpx @@ -115,6 +114,23 @@ else: """Never-raised placeholder for the absent msgraph extra.""" +# dnspython 2.9+ does DNS over HTTPS through httpx2 (installed by its +# [doh] extra); 2.7 and 2.8 use httpx, so httpx2 may be absent. It may +# also be present without dnspython having imported it: dnspython 2.9 +# needs Python 3.11, but httpx2 installs on 3.10 and other tools pull it +# in. _RedactUrlCredentialsFilter must recognize httpx2.URL whenever +# httpx2 can log one, so it is imported here rather than taken from +# dns.query (unlike utils.py's _doh_client_class(), which has to match +# the library dnspython itself imported). +if TYPE_CHECKING: + import httpx2 # pyright: ignore[reportMissingImports] +else: + try: + import httpx2 + except ModuleNotFoundError: + httpx2 = None + + from parsedmarc.constants import DEFAULT_DNS_MAX_RETRIES, DEFAULT_DNS_TIMEOUT from parsedmarc.log import logger import parsedmarc.mail @@ -687,12 +703,11 @@ _DEPENDENCY_LOGGERS = ( ) -# URL classes whose instances appear as log-record arguments. dnspython -# 2.9+ does DNS over HTTPS through httpx2, whose URL is not an httpx.URL; -# like _doh_client_class() in utils.py, take the module dnspython itself -# imported (absent under dnspython 2.7/2.8, which use httpx). -_HTTP_URL_CLASSES: tuple[type, ...] = (httpx.URL,) + tuple( - module.URL for module in (getattr(dns.query, "httpx2", None),) if module +# URL classes whose instances appear as log-record arguments. httpx2.URL +# is not an httpx.URL, so each installed library contributes its own +# class (see the httpx2 import guard above). +_HTTP_URL_CLASSES: tuple[type, ...] = (httpx.URL,) + ( + (httpx2.URL,) if httpx2 is not None else () ) diff --git a/tests/test_cli.py b/tests/test_cli.py index a9a65205..4886cafa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,6 +30,7 @@ from typing import cast from unittest.mock import MagicMock, patch import dns.message +import dns.query import dns.rcode import dns.rdata import dns.rdataclass @@ -5771,6 +5772,66 @@ class TestHttpx2LogRedaction(unittest.TestCase): self.assertEqual(len(installed), 1) +class TestHttpx2UrlClassImport(unittest.TestCase): + """GHSA-hjr3-rx4c-24cv: ``_HTTP_URL_CLASSES`` must recognize + ``httpx2.URL`` whenever httpx2 is importable, not only when dnspython + imported it. dnspython 2.9 requires Python >= 3.11, so on Python 3.10 + CI installs dnspython 2.8 (which never imports httpx2) while httpx2 + still installs there (hatch in the ``[build]`` extra requires it), so + a constant taken from ``dns.query`` left ``httpx2.URL`` arguments on + the ``httpx2`` logger unmasked there.""" + + def _exec_fresh_cli(self): + """Execute parsedmarc.cli's source into a throwaway module and + return it, leaving the canonical module untouched. Module import + adds a StreamHandler to the parsedmarc logger, so the handler list + is restored afterwards.""" + spec = importlib.util.find_spec("parsedmarc.cli") + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + saved = list(parsedmarc.log.logger.handlers) + try: + spec.loader.exec_module(mod) + finally: + parsedmarc.log.logger.handlers[:] = saved + return mod + + def test_httpx2_url_masked_when_dnspython_did_not_import_httpx2(self): + """Simulate dnspython 2.8 (no ``dns.query.httpx2``) with httpx2 + installed anyway: the httpx2 URL must still be masked. The old + constant, built from ``getattr(dns.query, "httpx2", None)``, left + it unredacted.""" + try: + import httpx2 # pyright: ignore[reportMissingImports] + except ModuleNotFoundError: + self.skipTest("httpx2 is not installed") + with patch.dict(dns.query.__dict__): + dns.query.__dict__.pop("httpx2", None) + fresh = self._exec_fresh_cli() + url = httpx2.URL("https://u:S3CRET@h.example/x?token=TOKSECRET") + self.assertEqual( + fresh._RedactUrlCredentialsFilter._redact(url), + "https://u:***@h.example/x?token=***", + ) + + def test_without_httpx2_only_httpx_urls_are_matched(self): + """With httpx2 unimportable (a None ``sys.modules`` entry makes + ``import httpx2`` raise ModuleNotFoundError), the module still + imports, ``httpx2`` is None, and httpx URLs are still masked. + This covers the fallback arm of the guard added for + GHSA-hjr3-rx4c-24cv.""" + with patch.dict(sys.modules, {"httpx2": None}): + fresh = self._exec_fresh_cli() + self.assertIsNone(fresh.httpx2) + self.assertEqual(fresh._HTTP_URL_CLASSES, (httpx.URL,)) + self.assertEqual( + fresh._RedactUrlCredentialsFilter._redact( + httpx.URL("https://u:S3CRET@h.example/x") + ), + "https://u:***@h.example/x", + ) + + class TestInitOutputClientsRollback(unittest.TestCase): """_init_output_clients() is all-or-nothing against the search backends' process-wide ``default`` connection alias.