|
| 1 | +- **Closed a CGNAT SSRF gap in the authority-source fetch guard (issue #2026).** |
| 2 | + `opencontractserver/utils/safe_http.py::_assert_public_ip` rejected resolved |
| 3 | + addresses via the `ipaddress` property denylist (`is_private` / `is_loopback` |
| 4 | + / `is_link_local` / `is_multicast` / `is_reserved` / `is_unspecified`), but |
| 5 | + RFC 6598 Carrier-Grade NAT shared address space (`100.64.0.0/10`) is |
| 6 | + classified as **neither** private **nor** reserved **nor** global on current |
| 7 | + CPython — verified `is_private == is_reserved == is_global == False` on 3.11 |
| 8 | + and 3.12 — so a host resolving into that block would have passed validation |
| 9 | + and been fetched. It is now rejected explicitly and version-independently via |
| 10 | + a `_CGNAT_NETWORK` membership check, with the CIDR pinned in |
| 11 | + `opencontractserver/constants/safe_http.py::CGNAT_SHARED_ADDRESS_SPACE_CIDR`. |
| 12 | + The `.gov` host allowlist already made exploitation hard in practice (an |
| 13 | + attacker would need an allowlisted host to resolve into the block), but the |
| 14 | + IP guard is defence-in-depth and is also reachable by callers that pass a |
| 15 | + custom `allowlist`. Regression coverage in |
| 16 | + `opencontractserver/tests/test_safe_http.py` rejects the CGNAT block and |
| 17 | + proves the adjacent public addresses (`100.63.255.255`, `100.128.0.0`) still |
| 18 | + pass. |
| 19 | +- **Closed an IPv4-mapped IPv6 bypass of the same guard.** `_assert_public_ip` |
| 20 | + now unwraps an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) to its embedded |
| 21 | + IPv4 before the property/CGNAT checks. On CPython 3.11 the IPv6 |
| 22 | + `is_private` / `_CGNAT_NETWORK` checks do not reflect the mapped IPv4 for the |
| 23 | + CGNAT-mapped form, so a resolver returning `::ffff:100.64.0.1` would have |
| 24 | + slipped past every check; unwrapping makes the guard version-independent for |
| 25 | + the mapped forms of private/loopback/link-local/CGNAT addresses. The CGNAT |
| 26 | + membership test is additionally guarded by `isinstance(ip, IPv4Address)` so a |
| 27 | + native IPv6 address is skipped rather than relying on `IPv6Address in |
| 28 | + IPv4Network` returning `False` (true only on CPython 3.11+; 3.10 raises |
| 29 | + `TypeError`). Covered by parametrized regressions in `test_safe_http.py` |
| 30 | + (mapped forms rejected; public native IPv6 passes). Other IPv6-embedded-IPv4 |
| 31 | + forms (NAT64 `64:ff9b::/96` + RFC 8215 `64:ff9b:1::/48`, 6to4 `2002::/16`, |
| 32 | + Teredo `2001:0::/32`, deprecated IPv4-compatible `::/96`) need no special |
| 33 | + handling — CPython already flags those whole prefixes `is_private`/ |
| 34 | + `is_reserved` on 3.11 and 3.12 — and `test_ipv6_embedded_ipv4_tunnels_rejected` |
| 35 | + now pins that coverage so a future Python change couldn't silently open the |
| 36 | + hole. |
| 37 | +- **Strip per-service credentials on cross-host redirects in `safe_fetch_bytes`.** |
| 38 | + Request headers are now dropped of `Authorization` / `Cookie` / |
| 39 | + `Proxy-Authorization` (`CROSS_HOST_STRIPPED_HEADERS`) when a redirect crosses to |
| 40 | + a different host (RFC 9110 §15.4). httpx — unlike browsers / `requests` — |
| 41 | + forwards request headers verbatim across origins, so without this a future |
| 42 | + caller passing a `.gov` API credential could leak it from one allowlisted host |
| 43 | + to another (e.g. `ecfr.gov` → `federalregister.gov`) while following a redirect. |
| 44 | + No current caller passes credentials, so this is forward-looking hardening; the |
| 45 | + default `User-Agent` is preserved across the hop. The cross-origin test compares |
| 46 | + `netloc` (host **and** port), so a same-host/different-port redirect |
| 47 | + (`ecfr.gov` → `ecfr.gov:9000`, a different service) also strips. Covered by |
| 48 | + `TestSafeFetchBytesCredentialStripping` (cross-host, cross-port, same-host). |
| 49 | +- **`_assert_public_ip` now fails CLOSED on an empty DNS result.** |
| 50 | + `socket.getaddrinfo` can return an empty list **without** raising `gaierror` on |
| 51 | + some resolver configs; the per-address loop would then be a no-op and the host |
| 52 | + declared safe (fail-open) while httpx still resolves independently at connect |
| 53 | + time. It now raises `SSRFValidationError` when no addresses resolve. Covered by |
| 54 | + `test_empty_getaddrinfo_rejected`. |
| 55 | +- **Redirect-loop robustness in `safe_fetch_bytes`.** The hop check now keys off |
| 56 | + `r.has_redirect_location` rather than `r.is_redirect` — in httpx `is_redirect` |
| 57 | + is true for *any* 3xx, so a `304 Not Modified` (or any non-`Location` 3xx) was |
| 58 | + treated as a redirect, resolved `Location: ""` back to the current URL, and |
| 59 | + looped to the redirect cap with a misleading "exceeded N redirects". A present- |
| 60 | + but-empty `Location:` now fails fast with a clear error, and a negative |
| 61 | + `Content-Length` (e.g. `-1`, which parsed cleanly and slipped the `> max_bytes` |
| 62 | + guard) is rejected as malformed. None are SSRF bypasses (the loop is bounded |
| 63 | + and the streamed-bytes cap is the real backstop), but they remove wasted hops |
| 64 | + and misleading diagnostics under server misbehaviour. Covered by |
| 65 | + `test_empty_location_header_rejected`, `test_non_location_3xx_not_followed_as_redirect`, |
| 66 | + and `test_negative_content_length_raises`. |
0 commit comments