Skip to content

readurls approach fetches attacker-named URLs with no SSRF guard (metadata/internal reachable on hosted proxy) #323

Description

@elfrost

Summary

The readurls approach fetches every URL it finds in a prompt with no restriction on the destination host, so a caller can steer the proxy into reading internal/link-local addresses (SSRF). This only bites when OptiLLM is exposed to callers you don't fully trust (--host 0.0.0.0, a shared/hosted proxy), but in that setup it's reachable purely from a chat request.

Where

optillm/plugins/readurls_plugin.pyfetch_webpage_content fetches whatever extract_urls pulled out of the prompt text:

# extract_urls: bare regex over the prompt
url_pattern = re.compile(r'https?://[^\s\'"]+')
...
# fetch_webpage_content:41-47
response = requests.get(url, headers=headers, timeout=10, verify=verify)

There's no host resolution and no allow/deny on the target — no block for RFC 1918, loopback, or link-local (169.254.0.0/16, which includes the cloud-metadata IP 169.254.169.254).

Why it's reachable

A caller selects the approach without any server-side opt-in — either via the request body (optillm_approach) or the model slug (readurls-<model>). On a proxy bound to 0.0.0.0, a request like:

messages: [{ role: "user", content: "summarize http://169.254.169.254/latest/meta-data/iam/security-credentials/" }]
model: "readurls-gpt-4o-mini"

makes the server fetch that URL and fold the response into the completion it returns to the caller — i.e. cloud IAM credentials or any internal service reachable from the proxy host.

To be clear about scope: this is not the core forward path (base_url is operator-set, so the main proxy can't be redirected) and it's not the executecode/z3 approaches (those run code by design and are honestly documented as such). It's specifically that readurls is meant to fetch a webpage and, without a destination guard, quietly reaches further than that.

Suggested fix

Resolve the host and refuse private/loopback/link-local ranges before fetching, unless an operator explicitly allows it. Roughly:

import ipaddress, socket
from urllib.parse import urlparse

def _is_blocked_host(url: str) -> bool:
    host = urlparse(url).hostname
    if not host:
        return True
    try:
        infos = socket.getaddrinfo(host, None)
    except socket.gaierror:
        return True
    for *_, sockaddr in infos:
        ip = ipaddress.ip_address(sockaddr[0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
            return True
    return False

and short-circuit in fetch_webpage_content when _is_blocked_host(url) is true (gated behind a config flag such as readurls_allow_internal for anyone who genuinely needs to fetch internal docs). Resolving once and reusing the resolved IP for the actual request would also close the DNS-rebind gap, though the range check is the main thing.

Happy to open a PR if that would help.


Found while reviewing open-source AI/LLM tools; fuller write-up (with what OptiLLM already does well) here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions