Skip to content

symfonic.capabilities.knowledge.outbound

outbound

The outbound URL guard: AS-NET-1 and AS-NET-2, before the socket.

A URL that reaches this module arrived inside model output, a tool result, or a document — somewhere untrusted — and something is about to fetch it. Two contracts apply:

  • Scheme allowlist (AS-NET-1). file:, gopher:, and data: are refused, so such a URL cannot turn a fetch into a local read.
  • Host controls (AS-NET-2). Loopback, link-local (169.254.169.254 is the cloud metadata endpoint), private, and unspecified targets are refused, because the interesting SSRF target is never on the public internet.

It lives apart from :mod:.multimodal because it answers a different question. The wire formatters decide what a provider is allowed to be told; this decides what the network stack is allowed to be asked for, and the two have no shared state — only the same OutboundRejected refusal.

Every check here is a string comparison against a canonical spelling, so the order is load-bearing: normalise, then compare. Sections below say why each alternate spelling exists.

check_outbound_url

check_outbound_url(url: str) -> str

Return url if it may be fetched, else raise :class:OutboundRejected.

Literal-address checks only. DNS resolution happens in whatever HTTP client performs the fetch, and the resolution-time and per-redirect checks AS-NET-2 also requires belong there, next to the socket — a guard that validates a name here and lets the client resolve it later is a time-of-check gap, not a control.

"Literal address" is read the way a C resolver reads it, not the way :func:ipaddress.ip_address does. 2130706433, 127.1 and 0177.0.0.1 all reach 127.0.0.1 without any DNS involvement, so a guard that only understands dotted-quad hands the loopback interface to anyone who writes the address a different way.

And the host is normalised through IDNA before any of that, because the same argument applies one level down: 127.0.0.1 is the loopback address written in fullwidth digits, and every check here is a comparison against the ASCII spelling. See :func:_normalise_hostname.

Source code in src/symfonic/capabilities/knowledge/outbound.py
def check_outbound_url(url: str) -> str:
    """Return ``url`` if it may be fetched, else raise :class:`OutboundRejected`.

    Literal-address checks only. DNS resolution happens in whatever HTTP client
    performs the fetch, and the resolution-time and per-redirect checks
    AS-NET-2 also requires belong there, next to the socket — a guard that
    validates a name here and lets the client resolve it later is a
    time-of-check gap, not a control.

    "Literal address" is read the way a C resolver reads it, not the way
    :func:`ipaddress.ip_address` does. ``2130706433``, ``127.1`` and
    ``0177.0.0.1`` all reach ``127.0.0.1`` without any DNS involvement, so a
    guard that only understands dotted-quad hands the loopback interface to
    anyone who writes the address a different way.

    And the host is normalised through IDNA *before* any of that, because the
    same argument applies one level down: ``127.0.0.1`` is the loopback
    address written in fullwidth digits, and every check here is a comparison
    against the ASCII spelling. See :func:`_normalise_hostname`.
    """
    parsed = urlparse(url)
    if parsed.scheme.lower() not in ALLOWED_URL_SCHEMES:
        raise OutboundRejected(
            f"outbound scheme {parsed.scheme!r} is not permitted; untrusted-derived "
            f"targets may use {sorted(ALLOWED_URL_SCHEMES)} only (AS-NET-1)."
        )
    hostname = (parsed.hostname or "").strip()
    if not hostname:
        raise OutboundRejected(
            f"outbound url {url!r} names no host; a target the framework cannot "
            "identify is a target it cannot check."
        )
    hostname = _normalise_hostname(hostname)
    # A trailing dot is the DNS root marker: 'localhost.' and '127.0.0.1.' reach
    # exactly what their undotted forms reach, so it is removed before any check
    # rather than allowed to route around all of them. It is removed *after*
    # normalisation because U+FF0E and U+3002 are dots too, and only IDNA knows
    # that.
    hostname = hostname.rstrip(".") or hostname
    if hostname.lower() in _DENIED_HOSTNAMES or hostname.lower().endswith(".localhost"):
        raise OutboundRejected(
            f"outbound host {hostname!r} names the local host; untrusted-derived "
            "targets may not reach the framework's own machine (AS-NET-2)."
        )
    _reject_numeric_literal(hostname)
    _reject_denied_address(hostname)
    return url