Skip to content

SSRF in graphlit-mcp-server retrieveImages via Unvalidated Local fetch() on User-Controlled URL #12

Description

@TianYu-0829

Summary

The retrieveImages tool in graphlit-mcp-server is vulnerable to Server-Side Request Forgery (SSRF). The user-supplied url parameter is passed directly to Node.js's global fetch() without any validation of the destination host, IP range, or protocol. This allows an attacker to make the MCP server process issue arbitrary HTTP requests to internal network services, cloud metadata endpoints, or any other host reachable from the server host — and the full response body is read into memory, Base64-encoded, and forwarded to the Graphlit cloud API as imageData for similarity search.


Detail

graphlit-mcp-server provides AI programming assistants (such as Claude, Gemini, and Cursor) with programmatic access to the [Graphlit Platform](https://www.graphlit.com/) knowledge API via the Model Context Protocol. The retrieveImages tool is intended to download a public image URL locally, convert it to Base64, and perform vector similarity search against images stored in the user's Graphlit project. However, it accepts an arbitrary URL string from the caller and fetches it directly on the MCP server host, bypassing the pattern used by other URL-based tools that delegate outbound HTTP to the Graphlit cloud backend.

The tool is registered via @modelcontextprotocol/sdk with an unconstrained url: z.string() parameter — any string value is accepted, including internal URLs:

Version: 1.0.1
File: src/tools.ts

  server.tool(
    "retrieveImages",
    `Retrieve images from Graphlit knowledge base. Provides image-specific retrieval when image similarity search is desired.
    Do *not* use for retrieving content by content identifier - retrieve content resource instead, with URI 'contents://{id}'.
    Accepts image URL. Image will be used for similarity search using image embeddings.
    Accepts optional geo-location filter for search by latitude, longitude and optional distance radius. Images taken with GPS enabled are searchable by geo-location.
    Also accepts optional recency filter (defaults to null, meaning all time), and optional feed and collection identifiers to filter images by.
    Returns the matching images, including their content resource URI to retrieve the complete Markdown text.`,
    {
      url: z
        .string()
        .describe(
          "URL of image which will be used for similarity search using image embeddings."
        ),
      inLast: z
        .string()
        .optional()
        .describe(
          "Recency filter for images ingested 'in last' timespan, optional. Should be ISO 8601 format, for example, 'PT1H' for last hour, 'P1D' for last day, 'P7D' for last week, 'P30D' for last month. Doesn't support weeks or months explicitly."
        ),
      feeds: z
        .array(z.string())
        .optional()
        .describe("Feed identifiers to filter images by, optional."),
      collections: z
        .array(z.string())
        .optional()
        .describe("Collection identifiers to filter images by, optional."),
      location: PointFilter.optional().describe(
        "Geo-location filter for search by latitude, longitude and optional distance radius."
      ),
      limit: z
        .number()
        .optional()
        .default(100)
        .describe(
          "Limit the number of images to be returned. Defaults to 100."
        ),
    },
    async ({ url, inLast, feeds, collections, location, limit }) => {
      const client = new Graphlit();

The handler passes the user-supplied string directly to fetch() with no intermediate validation. Notably, this fetch block sits outside the surrounding try/catch, so fetch failures propagate directly to the MCP runtime:

Version: 1.0.1
File: src/tools.ts

      var data;
      var mimeType;

      if (url) {
        const fetchResponse = await fetch(url);
        if (!fetchResponse.ok) {
          throw new Error(
            `Failed to fetch data from ${url}: ${fetchResponse.statusText}`
          );
        }
        const arrayBuffer = await fetchResponse.arrayBuffer();
        const buffer = Buffer.from(arrayBuffer);

        data = buffer.toString("base64");
        mimeType =
          fetchResponse.headers.get("content-type") ||
          "application/octet-stream";
      }

Unlike other URL-based tools in this server, retrieveImages does not delegate the outbound HTTP request to the Graphlit client SDK. ingestUrl passes the user-supplied URL to the Graphlit cloud API via client.ingestUri():

Version: 1.0.1
File: src/tools.ts

  server.tool(
    "ingestUrl",
    `Ingests content from URL into Graphlit knowledge base.
    Can scrape a single web page, and can ingest individual Word documents, PDFs, audio recordings, videos, images, or any other unstructured data.
    Do *not* use for crawling a web site, which is done with 'webCrawl' tool.
    Executes asynchronously and returns the content identifier.`,
    {
      url: z.string().describe("URL to ingest content from."),
    },
    async ({ url }) => {
      const client = new Graphlit();

      try {
        const response = await client.ingestUri(url);

Similarly, describeImageUrl delegates to client.describeImage():

Version: 1.0.1
File: src/tools.ts

  server.tool(
    "describeImageUrl",
    `Prompts vision LLM and returns completion. 
    Does *not* ingest image into Graphlit knowledge base.
    Accepts image URL as string.
    Returns Markdown text from LLM completion.`,
    {
      prompt: z.string().describe("Prompt for image description."),
      url: z.string().describe("Image URL."),
    },
    async ({ prompt, url }) => {
      const client = new Graphlit();

      try {
        const response = await client.describeImage(prompt, url);

A repository-wide search confirms that fetch() appears only once in the entire src/ directory — inside the retrieveImages handler at line 683. It is the sole local outbound HTTP entry point in this codebase.

After fetching, the full response body is Base64-encoded and sent to the Graphlit cloud API as part of a content query filter:

Version: 1.0.1
File: src/tools.ts

      try {
        const filter: ContentFilter = {
          imageData: data,
          imageMimeType: mimeType,
          searchType: SearchTypes.Vector,
          feeds: feeds?.map((feed) => ({ id: feed })),
          collections: collections?.map((collection) => ({ id: collection })),
          location: location,
          createdInLast: inLast,
          types: [ContentTypes.File],
          fileTypes: [FileTypes.Image],
          limit: limit,
        };
        const response = await client.queryContents(filter);

This creates a secondary data-exfiltration channel: internal resource content fetched via SSRF is transmitted to Graphlit's backend as imageData, in addition to any error/status information returned to the MCP client.

The tool is listed in the project README under the Retrieval section with no security constraints:

Version: 1.0.1
File: README.md

### Retrieval

- Query Contents
- Query Collections
- Query Feeds
- Query Conversations
- Retrieve Relevant Sources
- Retrieve Similar Images
- Visually Describe Image

There is no validation at any point in this call chain:

  • No allowlist of permitted domains
  • No blocklist of private/internal IP ranges (127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x)
  • No restriction on URL scheme (only http:/https: should be permitted)
  • No limit on HTTP redirects (Node.js fetch() follows redirects by default, enabling redirect-based bypass into internal space)
  • No timeout or response body size limit configured on the outbound request

On HTTP error responses, the handler throws Failed to fetch data from ${url}: ${fetchResponse.statusText}, providing a side channel for internal host/port enumeration. Network-level fetch failures (e.g. connection refused) also propagate uncaught from the fetch block, surfacing error details to the MCP client.

Clarified: This attack does not require OS-level shell access. The practically feasible triggering path is: an attacker performs a prompt injection into an AI agent integrated with graphlit-mcp-server, instructing the agent to invoke retrieveImages with a target internal URL (often framed as "find similar images to this reference image at this URL"). The agent, having no awareness of SSRF risks, will comply. No direct MCP client access is required beyond the ability to influence the agent's prompts (e.g., via crafted file content, web pages, emails, or ingested documents the agent is asked to process).

Note on other tools: Tools such as ingestUrl, screenshotPage, webCrawl, and describeImageUrl also accept user-controlled URL parameters, but they delegate outbound HTTP to the Graphlit cloud API rather than issuing requests from the MCP server process. The sole local-process SSRF entry point is the exposed retrieveImages MCP tool.

Proof of Concept Using MCP Inspector

Prerequisites

Configure Graphlit credentials (required for tool invocation):

# Obtain from https://portal.graphlit.dev → API Settings
export GRAPHLIT_ORGANIZATION_ID="<your-org-guid>"
export GRAPHLIT_ENVIRONMENT_ID="<your-env-guid>"
export GRAPHLIT_JWT_SECRET="<your-jwt-secret>"

Build and start the MCP server via Inspector:

cd graphlit-mcp-server
npm install
npm run build
npm run inspector

This launches the MCP Inspector web UI.

In a separate terminal, start a local HTTP listener to capture SSRF requests: python -m http.server 8000

Steps

  1. Open the Inspector URL in a browser and click Connect.
  2. Go to the Tools tab, click List Tools, and select retrieveImages.
  3. Invoke retrieveImages with the following parameters:

​ "url": "http://127.0.0.1:8000/ssrf-test"

  1. Observe the outgoing MCP request:
{
  "method": "tools/call",
  "params": {
    "name": "retrieveImages",
    "arguments": {
      "url": "http://127.0.0.1:8000/ssrf-test"
    },
    "_meta": {
      "progressToken": 0
    }
  }
}
  1. Observe the listener output — the MCP server process (not the browser) issues the request: ::ffff:127.0.0.1 - - [29/Jun/2026 09:22:04] "GET /ssrf-test HTTP/1.1" 404. The HTTP request originates from the Node.js process running dist/index.js, confirming that the server-side fetch is triggered by the tool call. Even if the Graphlit similarity search subsequently fails (because the fetched payload is not a valid image), the SSRF request has already been made and logged by the listener.

Impact

SSRF

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