|
20 | 20 |
|
21 | 21 | from __future__ import annotations |
22 | 22 |
|
| 23 | +import asyncio |
23 | 24 | import ipaddress |
24 | 25 | import os |
25 | 26 | import socket |
| 27 | +from concurrent.futures import ThreadPoolExecutor |
| 28 | +from concurrent.futures import TimeoutError as _FutureTimeout |
26 | 29 | from urllib.parse import urlparse |
27 | 30 |
|
28 | 31 | ALLOWED_BASE_URLS_ENV = "HEADROOM_ALLOWED_BASE_URLS" |
29 | 32 |
|
| 33 | +# `socket.getaddrinfo` has no timeout parameter and runs on whatever thread |
| 34 | +# calls it -- which, for the proxy, is the event loop. A caller-supplied host |
| 35 | +# that resolves slowly therefore stalls every other in-flight request, so the |
| 36 | +# lookup is bounded here and fails closed when it overruns. Callers already in |
| 37 | +# async context should prefer `is_safe_upstream_url_async`, which keeps the |
| 38 | +# wait off the loop entirely. |
| 39 | +RESOLVE_TIMEOUT_ENV = "HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S" |
| 40 | +_DEFAULT_RESOLVE_TIMEOUT_S = 3.0 |
| 41 | +_RESOLVER_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="hr-upstream-dns") |
| 42 | + |
| 43 | + |
| 44 | +def _resolve_timeout_seconds() -> float: |
| 45 | + raw = (os.environ.get(RESOLVE_TIMEOUT_ENV) or "").strip() |
| 46 | + if not raw: |
| 47 | + return _DEFAULT_RESOLVE_TIMEOUT_S |
| 48 | + try: |
| 49 | + value = float(raw) |
| 50 | + except ValueError: |
| 51 | + return _DEFAULT_RESOLVE_TIMEOUT_S |
| 52 | + return value if value > 0 else _DEFAULT_RESOLVE_TIMEOUT_S |
| 53 | + |
| 54 | + |
30 | 55 | _SAFE_SCHEMES = {"http", "https", "ws", "wss"} |
31 | 56 |
|
32 | 57 |
|
@@ -58,19 +83,53 @@ def _allowlisted_destinations() -> tuple[set[str], set[tuple[str, str, int]]] | |
58 | 83 | return hosts, origins |
59 | 84 |
|
60 | 85 |
|
| 86 | +# RFC 6052 / RFC 8215: these IPv6 prefixes embed an IPv4 address in their low |
| 87 | +# 32 bits, and `ipaddress` reports the well-known one as globally routable. On a |
| 88 | +# NAT64 network `64:ff9b::7f00:1` reaches 127.0.0.1, so the embedded address is |
| 89 | +# what has to be judged. 6to4, Teredo and IPv4-mapped forms are already caught |
| 90 | +# by the `is_global` test below. |
| 91 | +_NAT64_PREFIXES = ( |
| 92 | + ipaddress.IPv6Network("64:ff9b::/96"), |
| 93 | + ipaddress.IPv6Network("64:ff9b:1::/48"), |
| 94 | +) |
| 95 | + |
| 96 | + |
| 97 | +def _nat64_embedded_ipv4(addr: ipaddress.IPv6Address) -> ipaddress.IPv4Address | None: |
| 98 | + if not any(addr in prefix for prefix in _NAT64_PREFIXES): |
| 99 | + return None |
| 100 | + try: |
| 101 | + return ipaddress.IPv4Address(int(addr) & 0xFFFFFFFF) |
| 102 | + except (ipaddress.AddressValueError, ValueError): # pragma: no cover - defensive |
| 103 | + return None |
| 104 | + |
| 105 | + |
61 | 106 | def _is_internal_address(ip: str) -> bool: |
62 | 107 | try: |
63 | 108 | addr = ipaddress.ip_address(ip) |
64 | 109 | except ValueError: |
65 | 110 | return True # unparseable (e.g. scoped link-local) -> treat as unsafe |
66 | | - return ( |
| 111 | + if ( |
67 | 112 | addr.is_private |
68 | 113 | or addr.is_loopback |
69 | 114 | or addr.is_link_local |
70 | 115 | or addr.is_reserved |
71 | 116 | or addr.is_multicast |
72 | 117 | or addr.is_unspecified |
73 | | - ) |
| 118 | + ): |
| 119 | + return True |
| 120 | + # Anything not globally routable. This is what catches RFC 6598 shared |
| 121 | + # address space (100.64.0.0/10) -- which `is_private` does not flag, and |
| 122 | + # which reaches ISP and cloud-internal infrastructure -- along with |
| 123 | + # benchmarking (198.18/15), TEST-NET, 240/4, 6to4 and Teredo tunnels that |
| 124 | + # embed an internal IPv4, and any future special-use range the stdlib |
| 125 | + # learns about. |
| 126 | + if not addr.is_global: |
| 127 | + return True |
| 128 | + if isinstance(addr, ipaddress.IPv6Address): |
| 129 | + embedded = _nat64_embedded_ipv4(addr) |
| 130 | + if embedded is not None and _is_internal_address(str(embedded)): |
| 131 | + return True |
| 132 | + return False |
74 | 133 |
|
75 | 134 |
|
76 | 135 | def is_safe_upstream_url(url: str) -> bool: |
@@ -101,10 +160,22 @@ def is_safe_upstream_url(url: str) -> bool: |
101 | 160 | return (parsed.scheme.lower(), host.lower(), port) in origins |
102 | 161 |
|
103 | 162 | try: |
104 | | - infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) |
105 | | - except OSError: |
| 163 | + infos = _RESOLVER_POOL.submit( |
| 164 | + socket.getaddrinfo, host, None, 0, 0, socket.IPPROTO_TCP |
| 165 | + ).result(timeout=_resolve_timeout_seconds()) |
| 166 | + except (OSError, _FutureTimeout): |
106 | 167 | # Resolution and connection are separate operations, so allowing a DNS |
107 | 168 | # miss here would fail open if the name resolves on the later lookup. |
| 169 | + # A lookup that overruns the budget is treated the same way. |
108 | 170 | # Operators can explicitly allowlist split-horizon/internal endpoints. |
109 | 171 | return False |
110 | 172 | return all(not _is_internal_address(str(info[4][0])) for info in infos) |
| 173 | + |
| 174 | + |
| 175 | +async def is_safe_upstream_url_async(url: str) -> bool: |
| 176 | + """Async form of :func:`is_safe_upstream_url` for event-loop callers. |
| 177 | +
|
| 178 | + Same policy; the blocking resolution runs off the loop so a hostile or |
| 179 | + slow-resolving hostname cannot stall unrelated in-flight requests. |
| 180 | + """ |
| 181 | + return await asyncio.to_thread(is_safe_upstream_url, url) |
0 commit comments