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 <noreply@anthropic.com>
This commit is contained in:
Sean WhalenandClaude Opus 5.5 authored and GitHub committed 2026-10-31 19:32:04 -04:00
1 parent 48ea47adee
commit 2986c58b0f
2 files changed
+83 -7

No files matched your search

+22 -7
View File
@@ -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 ()
)
+61
View File
@@ -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.