1010from __future__ import annotations
1111
1212import re
13+ import socket
1314from contextlib import asynccontextmanager
1415from ipaddress import IPv6Address , ip_address
1516from typing import Any , AsyncIterator , Dict , Optional
@@ -55,49 +56,64 @@ def is_secure_url(url: str) -> bool:
5556 return True
5657
5758 # http:// is only allowed for loopback.
58- if host in _LOOPBACK_HOSTNAMES :
59- return True
59+ return _is_loopback_host (host )
6060
61- # Catch any other literal loopback IP that urlparse normalised
62- # (e.g. ``http://127.000.000.001``).
63- try :
64- return ip_address (host ).is_loopback
65- except ValueError :
66- return False
6761
62+ # An IPv4 literal in any of the spellings the resolver accepts: dotted
63+ # quads, fewer than four parts (``127.1``), a single integer
64+ # (``2130706433``), octal (``0177.0.0.1``) or hex (``0x7f000001``) parts.
65+ _RESOLVER_IPV4_LITERAL = re .compile (r"^[0-9a-fx.]+$" )
6866
69- def _ip_is_loopback_like (host : str ) -> bool :
70- """Return True if ``host`` is an IP literal that the local kernel will
71- route to the host running the agent.
67+
68+ def _is_loopback_host (host : str ) -> bool :
69+ """Return True if ``host`` names the machine running the agent.
70+
71+ ``host`` is a hostname as ``urlparse`` returns it (lowercase, no
72+ brackets). One classifier for every loopback decision -- the plain-HTTP
73+ allowance, the remote-manual rule, the redirect guard, the OpenAPI
74+ converter -- so they cannot disagree about what loopback is.
7275
7376 Wider than Python's stdlib ``ip_address(...).is_loopback`` because we
74- must also defend against:
75-
76- * ``0.0.0.0`` -- on Linux a TCP connect to 0.0.0.0 lands on 127.0.0.1.
77- * ``::`` -- the IPv6 equivalent of ``0.0.0.0``.
78- * IPv4-mapped IPv6 forms of any 127.0.0.0/8 address (e.g.
79- ``::ffff:127.0.0.1``, ``::ffff:127.0.0.2``) -- ``ipaddress`` does
80- not treat these as loopback per RFC 4291, but the dual-stack
81- socket layer routes them to the v4 loopback.
82-
83- Used by the OpenAPI converter to detect attacker-controlled
84- ``servers[0].url`` values that point at the agent's own loopback
85- interface (the GHSA-39j6-4867-gg4w SSRF pattern). Hostname-based,
86- never prefix-based.
77+ must classify every spelling the *resolver* will route locally:
78+
79+ * ``localhost`` and the canonical ``127.0.0.1`` / ``::1``.
80+ * A trailing dot (``localhost.``, ``127.0.0.1.``): the absolute-name
81+ form, which resolves the same.
82+ * ``0.0.0.0`` -- on Linux a TCP connect to 0.0.0.0 lands on 127.0.0.1;
83+ ``::`` is the IPv6 equivalent.
84+ * Any 127.0.0.0/8 address.
85+ * IPv4-mapped IPv6 forms of those (``::ffff:127.0.0.1``) -- not
86+ loopback per RFC 4291, but the dual-stack socket layer routes them
87+ to the v4 loopback.
88+ * The shorthand, integer, octal and hex IPv4 spellings ``inet_aton``
89+ accepts (``127.1``, ``2130706433``, ``0177.0.0.1``, ``0x7f000001``)
90+ -- ``ipaddress`` rejects them, the OS resolver does not.
91+
92+ Hostname-based, never prefix-based: ``localhost.evil.com`` and
93+ ``127.0.0.1.attacker.example`` are not loopback.
8794 """
88- if host in {"0.0.0.0" , "::" }:
95+ host = host .rstrip ("." )
96+ if not host :
97+ return False
98+ if host in _LOOPBACK_HOSTNAMES or host in {"0.0.0.0" , "::" }:
8999 return True
90100 try :
91101 addr = ip_address (host )
92102 except ValueError :
93- return False
94- if addr .is_loopback :
103+ # Not a form ``ipaddress`` understands; ask the resolver's parser.
104+ if not _RESOLVER_IPV4_LITERAL .match (host ):
105+ return False
106+ try :
107+ addr = ip_address (socket .inet_ntoa (socket .inet_aton (host )))
108+ except (OSError , ValueError ):
109+ return False
110+ if addr .is_loopback or addr .is_unspecified :
95111 return True
96112 # IPv4-mapped IPv6 loopback (``::ffff:127.0.0.1`` etc.) -- the
97113 # ``ipv4_mapped`` accessor surfaces the embedded v4 address.
98114 if isinstance (addr , IPv6Address ):
99115 mapped = addr .ipv4_mapped
100- if mapped is not None and mapped .is_loopback :
116+ if mapped is not None and ( mapped .is_loopback or mapped . is_unspecified ) :
101117 return True
102118 return False
103119
@@ -124,10 +140,7 @@ def is_loopback_url(url: str) -> bool:
124140 if not host :
125141 return False
126142
127- if host in _LOOPBACK_HOSTNAMES :
128- return True
129-
130- return _ip_is_loopback_like (host )
143+ return _is_loopback_host (host )
131144
132145
133146def ensure_secure_url (url : str , * , context : Optional [str ] = None ) -> None :
@@ -380,6 +393,14 @@ async def safe_request_with_redirects(
380393 target against the current URL and runs ``ensure_secure_url``
381394 on it before issuing the next hop. Rejection raises and the
382395 redirect chain is aborted with the connection released.
396+ * Never follows a redirect INTO loopback from a non-loopback URL.
397+ The loopback allowance in ``ensure_secure_url`` exists for
398+ requests the caller addressed to loopback (local development);
399+ a remote server must not be able to steer a request at the
400+ agent's own services, nor make a loopback-served manual look
401+ locally discovered (``reject_remote_loopback_tool_urls`` judges
402+ by the final URL, which this rule keeps honest: a final loopback
403+ URL means the chain started on loopback and never left it).
383404 * Caps the chain at ``max_redirects`` hops. Exceeding that raises
384405 ``RuntimeError``.
385406 * Mirrors RFC 7231 method semantics: 303 forces ``GET`` and drops
@@ -450,6 +471,15 @@ async def safe_request_with_redirects(
450471 ensure_secure_url (
451472 next_url , context = f"{ context } (redirect target)"
452473 )
474+ if is_loopback_url (next_url ) and not is_loopback_url (current_url ):
475+ raise ValueError (
476+ f"Security error during { context } (redirect target): "
477+ f"{ current_url !r} redirected to the loopback address "
478+ f"{ next_url !r} . A redirect is never followed into "
479+ "loopback from a non-loopback origin: the loopback "
480+ "allowance is for requests addressed to loopback by "
481+ "the caller, not for ones a remote server steers there."
482+ )
453483 except Exception :
454484 response .release ()
455485 raise
0 commit comments