A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 8, 03:45 UTC
[Core] Fix SSRF bypass via backslash-@ URL parsing inconsistency (#34743)
Signed-off-by: Russell Bryant <rbryant@redhat.com> Co-authored-by: isotr0py <2037008807@qq.com>
Russell Bryant committed
Feb 17, 2026 at 22:53 UTC
6f3b2047abd4a748e3db4a68543f8221358002c0
2 files changed
+59
-2
tests/multimodal/media/test_connector.py
+57
@@ -7,8 +7,10 @@ import mimetypes
7
import os
8
from tempfile import NamedTemporaryFile, TemporaryDirectory
9
10
+import aiohttp
11
import numpy as np
12
import pytest
13
+import requests
14
import torch
15
from PIL import Image, ImageChops
16
@@ -318,3 +320,58 @@ async def test_allowed_media_domains(video_url: str, num_frames: int):
320
321
with pytest.raises(ValueError):
322
_, _ = await connector.fetch_video_async(disallowed_url)
323
+
324
+
325
+@pytest.mark.asyncio
326
+async def test_ssrf_bypass_backslash_in_url(local_asset_server):
327
+ """Verify that backslash-@ URL parsing confusion cannot bypass the
328
+ allowed_media_domains check (GHSA-v359-jj2v-j536).
329
+
330
+ urllib3.parse_url() and aiohttp/yarl disagree on how to parse a
331
+ backslash before ``@``. urllib3 treats ``\\`` as part of the path
332
+ (encoding it as ``%5C``), while yarl treats it as a userinfo
333
+ separator, changing the effective host. The fix normalises the URL
334
+ through urllib3 *before* handing it to aiohttp so both layers agree.
335
+ """
336
+ port = local_asset_server.port
337
+ asset = TEST_IMAGE_ASSETS[0]
338
+
339
+ # Craft the bypass payload: urllib3 sees host=127.0.0.1, but an
340
+ # un-patched aiohttp would see host=example.com.
341
+ bypass_url = f"http://127.0.0.1:{port}\\@example.com/{asset}"
342
+
343
+ connector = MediaConnector(
344
+ allowed_media_domains=["127.0.0.1"],
345
+ )
346
+
347
+ # After the fix the request is made to 127.0.0.1 (the local asset
348
+ # server) using the normalised URL. The normalised path will be
349
+ # /%5C@example.com/<asset> which won't match any file the server
350
+ # knows about, so we expect an HTTP error — but crucially NOT a
351
+ # successful fetch from example.com.
352
+ with pytest.raises(requests.exceptions.HTTPError):
353
+ connector.fetch_image(bypass_url)
354
+
355
+ with pytest.raises(aiohttp.ClientResponseError):
356
+ await connector.fetch_image_async(bypass_url)
357
+
358
+
359
+@pytest.mark.asyncio
360
+async def test_ssrf_bypass_backslash_disallowed_domain():
361
+ """The reverse direction: even when the *attacker-controlled* host
362
+ appears in the urllib3-parsed hostname position the allowlist must
363
+ still block it.
364
+ """
365
+ # urllib3.parse_url sees host=example.com which is NOT in the
366
+ # allowlist, so this must be rejected before any request is made.
367
+ bypass_url = "https://example.com\\@safe.example.org/image.png"
368
+
369
+ connector = MediaConnector(
370
+ allowed_media_domains=["safe.example.org"],
371
+ )
372
+
373
+ with pytest.raises(ValueError, match="allowed domains"):
374
+ connector.fetch_image(bypass_url)
375
+
376
+ with pytest.raises(ValueError, match="allowed domains"):
377
+ await connector.fetch_image_async(bypass_url)
vllm/multimodal/media/connector.py
+2
-2
@@ -146,7 +146,7 @@ class MediaConnector:
146
147
connection = self.connection
148
data = connection.get_bytes(
149
- url,
149
+ url_spec.url,
150
timeout=fetch_timeout,
151
allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
152
)
@@ -177,7 +177,7 @@ class MediaConnector:
177
178
connection = self.connection
179
data = await connection.async_get_bytes(
180
- url,
180
+ url_spec.url,
181
timeout=fetch_timeout,
182
allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
183
)