PT-2026-60021 · Pypi · Vllm

Published

2026-07-13

·

Updated

2026-07-13

CVSS v3.1

5.4

Medium

VectorAV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L

Summary

A Server Side Request Forgery (SSRF) vulnerability in download bytes from url allows any actor who can control batch input JSON to make the vLLM batch runner issue arbitrary HTTP/HTTPS requests from the server, without any URL validation or domain restrictions.
This can be used to target internal services (e.g. cloud metadata endpoints or internal HTTP APIs) reachable from the vLLM host.

Details

Vulnerable component

The vulnerable logic is in the batch runner entrypoint vllm/entrypoints/openai/run batch.py, function download bytes from url:
# run batch.py Lines 442-482
async def download bytes from url(url: str) -> bytes:
  """
  Download data from a URL or decode from a data URL.

  Args:
    url: Either an HTTP/HTTPS URL or a data URL (data:...;base64,...)

  Returns:
    Data as bytes
  """
  parsed = urlparse(url)

  # Handle data URLs (base64 encoded)
  if parsed.scheme == "data":
    # Format: data:...;base64,<base64 data>
    if "," in url:
      header, data = url.split(",", 1)
      if "base64" in header:
        return base64.b64decode(data)
      else:
        raise ValueError(f"Unsupported data URL encoding: {header}")
    else:
      raise ValueError(f"Invalid data URL format: {url}")

  # Handle HTTP/HTTPS URLs
  elif parsed.scheme in ("http", "https"):
    async with (
      aiohttp.ClientSession() as session,
      session.get(url) as resp,
    ):
      if resp.status != 200:
        raise Exception(
          f"Failed to download data from URL: {url}. Status: {resp.status}"
        )
      return await resp.read()

  else:
    raise ValueError(
      f"Unsupported URL scheme: {parsed.scheme}. "
      "Supported schemes: http, https, data"
    )
Key properties:
  • The function only parses the URL to dispatch on the scheme (data, http, https).
  • For http / https, it directly calls session.get(url) on the provided string.
  • There is no validation of:
  • hostname or IP address,
  • whether the target is internal or external,
  • port number,
  • path, query, or redirect target.
  • This is in contrast to the multimodal media path (MediaConnector), which implements an explicit domain allowlist. download bytes from url does not reuse that protection.

URL controllability

The url argument is fully controlled by batch input JSON via the file url field of BatchTranscriptionRequest / BatchTranslationRequest.
  1. Batch request body type:
# run batch.py Line 67-80
class BatchTranscriptionRequest(TranscriptionRequest):
  """
  Batch transcription request that uses file url instead of file.

  This class extends TranscriptionRequest but replaces the file field
  with file url to support batch processing from audio files written in JSON format.
  """

  file url: str = Field(
    ...,
    description=(
      "Either a URL of the audio or a data URL with base64 encoded audio data. "
    ),
  )
# run batch.py Line 98-111
class BatchTranslationRequest(TranslationRequest):
  """
  Batch translation request that uses file url instead of file.

  This class extends TranslationRequest but replaces the file field
  with file url to support batch processing from audio files written in JSON format.
  """

  file url: str = Field(
    ...,
    description=(
      "Either a URL of the audio or a data URL with base64 encoded audio data. "
    ),
  )
There is no restriction on the domain, IP, or port of file url in these models.
  1. Batch input is parsed directly from the batch file:
# run batch.py Line 139-179
class BatchRequestInput(OpenAIBaseModel):
  ...
  url: str
  body: BatchRequestInputBody
  @field validator("body", mode="plain")
  @classmethod
  def check type for url(cls, value: Any, info: ValidationInfo):
    url: str = info.data["url"]
    ...
    if url == "/v1/audio/transcriptions":
      return BatchTranscriptionRequest.model validate(value)
    if url == "/v1/audio/translations":
      return BatchTranslationRequest.model validate(value)
# run batch.py Line 770-781
  logger.info("Reading batch from %s...", args.input file)

  # Submit all requests in the file to the engine "concurrently".
  response futures: list[Awaitable[BatchRequestOutput]] = []
  for request json in (await read file(args.input file)).strip().split("
"):
    # Skip empty lines.
    request json = request json.strip()
    if not request json:
      continue

    request = BatchRequestInput.model validate json(request json)
The batch runner reads each line of the input file (args.input file), parses it as JSON, and constructs a BatchTranscriptionRequest / BatchTranslationRequest. Whatever file url appears in that JSON line becomes batch request body.file url.
  1. file url is passed directly into download bytes from url:
# run batch.py Line 610-623
def wrapper(handler fn: Callable):
    async def transcription wrapper(
      batch request body: (BatchTranscriptionRequest | BatchTranslationRequest),
    ) -> (
      TranscriptionResponse
      | TranscriptionResponseVerbose
      | TranslationResponse
      | TranslationResponseVerbose
      | ErrorResponse
    ):
      try:
        # Download data from URL
        audio data = await download bytes from url(batch request body.file url)
So the data flow is:
  1. Attacker supplies JSON line in the batch input file with arbitrary body.file url.
  2. BatchRequestInput / BatchTranscriptionRequest / BatchTranslationRequest parse that JSON and store file url verbatim.
  3. make transcription wrapper calls download bytes from url(batch request body.file url).
  4. download bytes from url’s HTTP/HTTPS branch issues aiohttp.ClientSession().get(url) to that attacker-controlled URL with no further validation.
This is a classic SSRF pattern: a server-side component makes arbitrary HTTP requests to a URL string taken from untrusted input.

Comparison with safer code

The project already contains a safer URL-handling path for multimodal media in vllm/multimodal/media/connector.py, which demonstrates the intent to mitigate SSRF via domain allowlists and URL normalization:
# connector.py Lines 169-189
 def load from url(
    self,
    url: str,
    media io: MediaIO[ M],
    *,
    fetch timeout: int | None = None,
  ) -> M: # type: ignore[type-var]
    url spec = parse url(url)

    if url spec.scheme and url spec.scheme.startswith("http"):
      self. assert url in allowed media domains(url spec)

      connection = self.connection
      data = connection.get bytes(
        url spec.url,
        timeout=fetch timeout,
        allow redirects=envs.VLLM MEDIA URL ALLOW REDIRECTS,
      )

      return media io.load bytes(data)
and:
# connector.py Lines 158-167
 def assert url in allowed media domains(self, url spec: Url) -> None:
    if (
      self.allowed media domains
      and url spec.hostname not in self.allowed media domains
    ):
      raise ValueError(
        f"The URL must be from one of the allowed domains: "
        f"{self.allowed media domains}. Input URL domain: "
        f"{url spec.hostname}"
      )
download bytes from url does not reuse this allowlist or any equivalent validation, even though it also fetches user-provided URLs.

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-3410

Affected Products

Vllm