From bf1ca227dd72cb40c546f993af784d16a699f6a6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 2 Jul 2026 18:04:28 +0000 Subject: [PATCH] fix(tts): block non-globally-routable addrs (CGNAT/Tailscale 100.64.0.0/10) via not-is_global backstop + tests (Opus follow-up on #5407) --- api/routes.py | 14 ++++++++++++-- tests/test_issue4982_openai_tts.py | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/api/routes.py b/api/routes.py index 3de180a9..87d202b8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -16302,7 +16302,16 @@ _TTS_LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost"} def _tts_addr_is_blocked(ip_str: str) -> bool: - """Return True when IP is in a private or otherwise non-routable class.""" + """Return True when IP is in a private or otherwise non-routable class. + + The explicit flags below document the concrete SSRF-risk classes, but the + load-bearing rule is the ``not is_global`` backstop: it blocks every address + that is not globally routable — including ranges the named flags miss, most + notably ``100.64.0.0/10`` (RFC 6598 CGNAT, also Tailscale's default address + space), the ``198.18.0.0/15`` benchmarking range, and any future + non-global allocation — so a rebinding host cannot reach a victim's tailnet + or carrier-NAT peer. + """ import ipaddress try: @@ -16310,7 +16319,8 @@ def _tts_addr_is_blocked(ip_str: str) -> bool: except ValueError: return False return ( - ip.is_private + not ip.is_global + or ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved diff --git a/tests/test_issue4982_openai_tts.py b/tests/test_issue4982_openai_tts.py index c910af0a..4b19d0ee 100644 --- a/tests/test_issue4982_openai_tts.py +++ b/tests/test_issue4982_openai_tts.py @@ -201,6 +201,33 @@ def test_tts_resolve_pinned_address_rejects_blocked_target(monkeypatch): routes._tts_resolve_pinned_address("public.example.com") +def test_tts_addr_is_blocked_covers_non_global_ranges(): + # The `not is_global` backstop must block ranges the named private/loopback/ + # link-local flags miss — most importantly RFC 6598 CGNAT (100.64.0.0/10, + # also Tailscale's default space) so a rebinding host can't reach a victim's + # tailnet/carrier-NAT peer — while genuine public addresses stay allowed. + assert routes._tts_addr_is_blocked("100.64.0.1") is True # CGNAT / Tailscale + assert routes._tts_addr_is_blocked("100.127.255.254") is True # CGNAT upper edge + assert routes._tts_addr_is_blocked("198.18.0.1") is True # benchmarking (RFC 2544) + assert routes._tts_addr_is_blocked("192.0.2.5") is True # TEST-NET-1 (docs, non-global) + # Real public addresses still pass through: + assert routes._tts_addr_is_blocked("1.1.1.1") is False + assert routes._tts_addr_is_blocked("8.8.8.8") is False + assert routes._tts_addr_is_blocked("140.82.112.3") is False # github.com range + # 203.0.113.0/24 is TEST-NET-3 (documentation, non-global) -> blocked too: + assert routes._tts_addr_is_blocked("203.0.113.10") is True + + +def test_tts_resolve_pinned_address_rejects_cgnat_rebind_target(monkeypatch): + # A host that resolves into the CGNAT/Tailscale range must be rejected at + # pinning time (regression for the 100.64.0.0/10 blocklist gap). + def _fake_getaddrinfo(*_args, **_kwargs): + return [(0, 0, 0, "", ("100.64.12.34", 0))] + monkeypatch.setattr(socket, "getaddrinfo", _fake_getaddrinfo) + with pytest.raises(ValueError, match="not allowed"): + routes._tts_resolve_pinned_address("tailnet-rebind.example.com") + + def test_tts_resolve_pinned_address_rejects_mixed_addresses(monkeypatch): def _fake_getaddrinfo(*_args, **_kwargs): return [