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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions