Summary
A Server Side Request Forgery (SSRF) vulnerability in downloadbytesfromurl 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/runbatch.py, function downloadbytesfromurl:
runbatch.py Lines 442-482 async def downloadbytesfromurl(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,<base64data> 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. downloadbytesfromurl does not reuse that protection.
URL controllability
The url argument is fully controlled by batch input JSON via the fileurl field of BatchTranscriptionRequest / BatchTranslationRequest.
1. Batch request body type:
runbatch.py Line 67-80 class BatchTranscriptionRequest(TranscriptionRequest): """ Batch transcription request that uses fileurl instead of file.
This class extends TranscriptionRequest but replaces the file field with fileurl to support batch processing from audio files written in JSON format. """
fileurl: str = Field( ..., description=( "Either a URL of the audio or a data URL with base64 encoded audio data. " ), )
runbatch.py Line 98-111 class BatchTranslationRequest(TranslationRequest): """ Batch translation request that uses fileurl instead of file.
This class extends TranslationRequest but replaces the file field with fileurl to support batch processing from audio files written in JSON format. """
fileurl: 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 fileurl in these models.
1. Batch input is parsed directly from the batch file:
runbatch.py Line 139-179 class BatchRequestInput(OpenAIBaseModel): ... url: str body: BatchRequestInputBody @fieldvalidator("body", mode="plain") @classmethod def checktypeforurl(cls, value: Any, info: ValidationInfo): url: str = info.data["url"] ... if url == "/v1/audio/transcriptions": return BatchTranscriptionRequest.modelvalidate(value) if url == "/v1/audio/translations": return BatchTranslationRequest.modelvalidate(value)
runbatch.py Line 770-781 logger.info("Reading batch from %s...", args.inputfile)
# Submit all requests in the file to the engine "concurrently". responsefutures: list[Awaitable[BatchRequestOutput]] = [] for requestjson in (await readfile(args.inputfile)).strip().split("\n"): # Skip empty lines. requestjson = requestjson.strip() if not requestjson: continue
request = BatchRequestInput.modelvalidatejson(requestjson)
The batch runner reads each line of the input file (args.inputfile), parses it as JSON, and constructs a BatchTranscriptionRequest / BatchTranslationRequest. Whatever fileurl appears in that JSON line becomes batchrequestbody.fileurl.
1. fileurl is passed directly into downloadbytesfromurl:
runbatch.py Line 610-623 def wrapper(handlerfn: Callable): async def transcriptionwrapper( batchrequestbody: (BatchTranscriptionRequest | BatchTranslationRequest), ) -> ( TranscriptionResponse | TranscriptionResponseVerbose | TranslationResponse | TranslationResponseVerbose | ErrorResponse ): try: # Download data from URL audiodata = await downloadbytesfromurl(batchrequestbody.fileurl)
So the data flow is:
1. Attacker supplies JSON line in the batch input file with arbitrary body.fileurl. 2. BatchRequestInput / BatchTranscriptionRequest / BatchTranslationRequest parse that JSON and store fileurl verbatim. 3. maketranscriptionwrapper calls downloadbytesfromurl(batchrequestbody.fileurl). 4. downloadbytesfromurl’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 loadfromurl( self, url: str, mediaio: MediaIO[M], , fetchtimeout: int | None = None, ) -> M: # type: ignore[type-var] urlspec = parseurl(url)
if urlspec.scheme and urlspec.scheme.startswith("http"): self.asserturlinallowedmediadomains(urlspec)
connection = self.connection data = connection.getbytes( urlspec.url, timeout=fetchtimeout, allowredirects=envs.VLLMMEDIAURLALLOWREDIRECTS, )
return mediaio.loadbytes(data)
and:
connector.py Lines 158-167 def asserturlinallowedmediadomains(self, urlspec: Url) -> None: if ( self.allowedmediadomains and urlspec.hostname not in self.allowedmediadomains ): raise ValueError( f"The URL must be from one of the allowed domains: " f"{self.allowedmediadomains}. Input URL domain: " f"{urlspec.hostname}" )
downloadbytesfromurl does not reuse this allowlist or any equivalent validation, even though it also fetches user-provided URLs.
Summary
vLLM's /v1/responses endpoint accepts request-level chattemplatekwargs, but does not apply the --trust-request-chat-template gate that protects Chat Completions, render, tokenize, and pooling paths.
When the server is run with the documented default trustrequestchattemplate=False and no explicit --chat-template, a Responses request can pass:
json {"chattemplatekwargs": {"chattemplate": "<attacker Jinja template>"}}
That key is preserved by ChatParams.getapplychattemplatekwargs() and then bound as the chattemplate parameter to the shared HF renderer. Prior vLLM advisories establish that request-controlled Jinja chat templates can cause CPU/memory exhaustion. This report covers the current-head Responses sibling path missed by the earlier fixes.
Technical Details
The shared OpenAI frontend has a validation helper that rejects request-supplied chat templates unless the trust flag is enabled. It explicitly checks both top-level chattemplate and chattemplatekwargs["chattemplate"].
Responses is a separate serving path:
- vllm/entrypoints/openai/responses/protocol.py exposes chattemplatekwargs. - ResponsesRequest.buildchatparams() merges request kwargs. - OpenAIServingResponses.effectivechattemplatekwargs() uses those kwargs. - ChatParams.getapplychattemplatekwargs() preserves a request chattemplate key when the server chat template is None. - vllm/renderers/hf.py unpacks the result into safeapplychattemplate.
There is no equivalent trust gate in vllm/entrypoints/openai/responses/serving.py.
Impact
An authenticated caller who can submit /v1/responses requests can supply a request-controlled Jinja chat template despite the trust flag being disabled. The expected fixed behavior is the same as Chat Completions: reject chattemplatekwargs["chattemplate"] unless the server was started with --trust-request-chat-template.
Suggested Fix
- Add trustrequestchattemplate to OpenAIServingResponses. - Pass args.trustrequestchattemplate when constructing the Responses serving object. - Reject request.chattemplatekwargs["chattemplate"] before rendering when trust is false. - Keep ordinary kwargs such as enablethinking working. - Add regression tests for default-deny and opt-in-allow behavior on /v1/responses.
Affected Package/Versions
Source sweep found the affected shape in:
- v0.16.0 - v0.18.0 - v0.20.0 - v0.21.0 - v0.22.0 - v0.22.1 - v0.23.0 - current checkout 470229c37efaf69c86e8bc97482b0b1ff7551c65 - current origin/main b3f0a0a0df76dda92ec4b2c9335f77e84adad911
Suggested affected range:
text = 0.16.0, <= 0.23.0
Appendix: Duplicate Research
This is related to, but distinct from:
- GHSA-6fvq-23cw-5628 / CVE-2025-61620 - GHSA-69j4-grxj-j64p / CVE-2025-62426
Those advisories establish the security impact of request-controlled Jinja templates and chattemplatekwargs, but they cover older Chat Completions/tokenize implementations. This report covers a current /v1/responses sibling path that still lacks the trust gate.
Local prior submissions this report..029 do not cover this root cause.
This advisory was fixed in MR50300.