Skip to content

marqo: unauthenticated SSRF via media URLs in add_documents #1452

Description

@geo-chen

Summary

Marqo's API is unauthenticated by default and binds 0.0.0.0. During indexing it fetches media from user-supplied URLs with no destination filtering. download_image_from_url uses pycurl with FOLLOWLOCATION=1 and no IP/host filter; fetch_content_sample uses requests.get on the raw URL. The only check is validators.url (syntax). So an unauthenticated attacker can make the backend request arbitrary internal addresses.

Proof of concept (real container)

POST /indexes/ssrf-test (no auth) -> created.
POST /indexes/ssrf-test/documents {"documents":[{"_id":"d","img":"http://INTERNAL:PORT/x.png"}],"tensorFields":["img"]}
-> my listener received the request FROM the marqo container (172.17.0.2), UA=Marqobot/1.0. A no-extension URL produced extra python-requests hits (MIME sniff). img=http://127.0.0.1:8882/ returned status 200 (internal service reachable). On cloud, http://169.254.169.254/ is reachable.

This is a blind SSRF (the body is processed as an image, not returned) - a reachability oracle and pivot primitive.

Suggested fix

Resolve and reject private/loopback/link-local/reserved IPs before fetching (re-check on redirects, since FOLLOWLOCATION is on), restrict scheme to http/https, and consider disabling redirect following for media fetches.

Activity

  1. Vansh-Sharma27 commented on Jul 26, 2026

    @Vansh-Sharma27

    I've opened #1464 with a fix.

    Both paths from the report are covered.

    download_image_from_url checks the address libcurl resolved, using
    CURLOPT_OPENSOCKETFUNCTION. libcurl calls that before opening the connection and again
    for each redirect it follows, so a refused address is never connected to and timing does
    not show whether a port is open. PROTOCOLS and REDIR_PROTOCOLS are set to http and
    https, since otherwise a file:// URL reads the container filesystem.

    fetch_content_sample has no equivalent hook in requests, so it resolves the host itself
    and follows redirects one hop at a time, checking each hop before requesting it.

    One thing I found while tracing this: infer_modality also runs on search queries
    (tensor_search.py:664, 672, 1051, 1060), so this is reachable from
    /indexes/{index}/search as well as from add_documents.

    The PoC in this issue, the metadata endpoint, private subnets, [::1],
    [::ffff:127.0.0.1], example.com@127.0.0.1, file:// and redirect to an internal
    address all have tests, and they fail on mainline. Addresses that are not publicly
    routable are refused by default, with MARQO_MEDIA_DOWNLOAD_ALLOWED_NETWORKS for
    deployments that serve media from a private network.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions