mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-07 01:09:26 +00:00
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:
1 parent
48ea47adee
commit
2986c58b0f
2 files changed
+83
-7
No files matched your search
+22
-7
@@ -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 ()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user