Skip to content

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

Closed

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.py — fetch_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.

Activity

  1. modelpath-dev commented on Sep 10, 2026

    @modelpath-dev

    I will take this issue. Please assign it to me.

    The problem is in optillm/plugins/readurls_plugin.py. The fetch_webpage_content function fetches URLs without checking if they are internal or unsafe. This can lead to SSRF vulnerabilities. I would first inspect the extract_urls and fetch_webpage_content functions. Then, I would add a check to block private, loopback, and link-local IP ranges before making a request. This should prevent unauthorized access to internal resources.

  2. modelpath-dev commented on Sep 13, 2026

    @modelpath-dev

    I am working on this issue. I will add a check to block private, loopback, and link-local IP ranges in the fetch_webpage_content function. This should help prevent unauthorized access to internal resources. Let me know if there are any specific requirements or suggestions.

  3. akushonkamen commented on Oct 10, 2026

    @akushonkamen
    Contributor

    Hi @elfrost — I'd like to pick this up. I noticed @modelpath-dev claimed it in September but no PR has landed after ~4 weeks, and PR #326 (which took the same approach) was closed unmerged by its author. To avoid another stall I'll submit a lean version.

    Root cause: optillm/plugins/readurls_plugin.py:47 — fetch_webpage_content calls requests.get() on any URL extracted from the prompt with no restriction on the destination host (no RFC1918/loopback/link-local check, so cloud metadata 169.254.169.254 is reachable).

    Plan: pure-stdlib guard (ipaddress + socket.getaddrinfo) that refuses non-public/reserved hosts before fetching, fail-closed on DNS errors, plus redirect re-validation (each hop is re-checked, so a public URL can't 302 into an internal address). Default-deny with an explicit opt-out flag for operators who need internal fetches. Offline unit tests following the existing tests/test_ssl_config.py mock pattern. PR incoming shortly.

  4. added a commit that references this issue on Oct 10, 2026
    4088efc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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