Skip to content

Commit 4069c58

Browse files
Fix security vuls
Signed-off-by: Onur Yilmaz <oyilmaz@nvidia.com>
1 parent 7cdfb32 commit 4069c58

4 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import ipaddress
2+
import socket
3+
from urllib.parse import urlparse
4+
5+
# Ranges that must never be reachable via a request-controlled image URL.
6+
# 169.254.0.0/16 is the cloud IMDS range (AWS/GCP/Azure 169.254.169.254) —
7+
# the primary SSRF target in cloud deployments.
8+
_BLOCKED_NETWORKS = [
9+
ipaddress.ip_network("127.0.0.0/8"), # loopback — server's own local services
10+
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private
11+
ipaddress.ip_network("172.16.0.0/12"), # RFC 1918 private
12+
ipaddress.ip_network("192.168.0.0/16"), # RFC 1918 private
13+
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud IMDS
14+
ipaddress.ip_network("::1/128"), # IPv6 loopback
15+
ipaddress.ip_network("fc00::/7"), # IPv6 unique-local
16+
]
17+
18+
19+
def validate_image_url(url: str) -> None:
20+
"""Raise ValueError if url is not a safe http/https URL.
21+
22+
Rejects file://, non-http(s) schemes, and URLs that resolve to
23+
private/link-local/loopback ranges (SSRF guard).
24+
"""
25+
parsed = urlparse(url)
26+
if parsed.scheme not in ("http", "https"):
27+
raise ValueError(
28+
f"Unsupported image URL scheme '{parsed.scheme}'. "
29+
"Only http and https are allowed."
30+
)
31+
hostname = parsed.hostname
32+
if not hostname:
33+
raise ValueError("Image URL has no hostname.")
34+
try:
35+
resolved_ip = ipaddress.ip_address(socket.gethostbyname(hostname))
36+
except (socket.gaierror, ValueError) as exc:
37+
raise ValueError(f"Cannot resolve image URL hostname '{hostname}': {exc}") from exc
38+
for net in _BLOCKED_NETWORKS:
39+
if resolved_ip in net:
40+
raise ValueError(
41+
f"Image URL resolves to a blocked address ({resolved_ip}). "
42+
"Private, loopback, and link-local addresses are not allowed."
43+
)

nemo_deploy/multimodal/megatron_multimodal_deployable.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ def process_image_input(self, image_source):
173173
if isinstance(self.inference_wrapped_model, QwenVLInferenceWrapper):
174174
from qwen_vl_utils import process_vision_info
175175

176+
from nemo_deploy.multimodal.image_url_validator import validate_image_url
177+
178+
# data: URIs are inline base64 and never trigger a network request.
179+
# All other values are treated as URLs and must pass the SSRF guard.
180+
if not image_source.startswith("data:"):
181+
validate_image_url(image_source)
182+
176183
messages = [
177184
{
178185
"role": "user",

nemo_deploy/multimodal/query_multimodal.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ def setup_media(self, input_media):
100100
raise UnavailableError(MISSING_PIL_MSG)
101101

102102
if input_media.startswith("http") or input_media.startswith("https"):
103+
from nemo_deploy.multimodal.image_url_validator import validate_image_url
104+
105+
validate_image_url(input_media)
103106
response = requests.get(input_media, timeout=5)
104107
media = Image.open(BytesIO(response.content)).convert("RGB")
105108
else:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import importlib.util
16+
import pathlib
17+
import socket
18+
from unittest.mock import patch
19+
20+
import pytest
21+
22+
# Load the validator directly by file path so we don't trigger nemo_deploy/__init__.py
23+
# (which requires torch/triton). The module itself is pure stdlib.
24+
_validator_path = (
25+
pathlib.Path(__file__).resolve().parents[3]
26+
/ "nemo_deploy"
27+
/ "multimodal"
28+
/ "image_url_validator.py"
29+
)
30+
_spec = importlib.util.spec_from_file_location("image_url_validator", _validator_path)
31+
_mod = importlib.util.module_from_spec(_spec)
32+
_spec.loader.exec_module(_mod)
33+
validate_image_url = _mod.validate_image_url
34+
35+
36+
def _mock_resolve(ip_str):
37+
"""Return a patch for socket.gethostbyname that always resolves to ip_str."""
38+
return patch.object(_mod.socket, "gethostbyname", return_value=ip_str)
39+
40+
41+
class TestBlockedSchemes:
42+
def test_file_scheme_rejected(self):
43+
with pytest.raises(ValueError, match="scheme"):
44+
validate_image_url("file:///etc/passwd")
45+
46+
def test_ftp_scheme_rejected(self):
47+
with pytest.raises(ValueError, match="scheme"):
48+
validate_image_url("ftp://example.com/img.png")
49+
50+
def test_no_scheme_rejected(self):
51+
with pytest.raises(ValueError, match="scheme"):
52+
validate_image_url("example.com/img.png")
53+
54+
55+
class TestBlockedRanges:
56+
def test_loopback_ipv4_rejected(self):
57+
with _mock_resolve("127.0.0.1"):
58+
with pytest.raises(ValueError, match="blocked address"):
59+
validate_image_url("http://localhost/img.jpg")
60+
61+
def test_loopback_other_subnet_rejected(self):
62+
with _mock_resolve("127.1.2.3"):
63+
with pytest.raises(ValueError, match="blocked address"):
64+
validate_image_url("http://internal.local/img.jpg")
65+
66+
def test_cloud_imds_rejected(self):
67+
# 169.254.169.254 is the AWS/GCP/Azure metadata service
68+
with _mock_resolve("169.254.169.254"):
69+
with pytest.raises(ValueError, match="blocked address"):
70+
validate_image_url("http://169.254.169.254/latest/meta-data/")
71+
72+
def test_link_local_rejected(self):
73+
with _mock_resolve("169.254.0.1"):
74+
with pytest.raises(ValueError, match="blocked address"):
75+
validate_image_url("http://169.254.0.1/img.jpg")
76+
77+
def test_rfc1918_10_rejected(self):
78+
with _mock_resolve("10.0.0.1"):
79+
with pytest.raises(ValueError, match="blocked address"):
80+
validate_image_url("http://internal.corp/img.jpg")
81+
82+
def test_rfc1918_172_rejected(self):
83+
with _mock_resolve("172.16.0.1"):
84+
with pytest.raises(ValueError, match="blocked address"):
85+
validate_image_url("http://internal.corp/img.jpg")
86+
87+
def test_rfc1918_192_rejected(self):
88+
with _mock_resolve("192.168.1.1"):
89+
with pytest.raises(ValueError, match="blocked address"):
90+
validate_image_url("http://192.168.1.1/img.jpg")
91+
92+
93+
class TestAllowedUrls:
94+
def test_public_https_allowed(self):
95+
with _mock_resolve("93.184.216.34"): # example.com
96+
validate_image_url("https://example.com/image.jpg") # must not raise
97+
98+
def test_public_http_allowed(self):
99+
with _mock_resolve("1.2.3.4"):
100+
validate_image_url("http://cdn.example.com/image.png") # must not raise
101+
102+
103+
class TestNoHostname:
104+
def test_url_without_hostname_rejected(self):
105+
with pytest.raises(ValueError, match="hostname"):
106+
validate_image_url("http:///image.jpg")
107+
108+
def test_dns_failure_rejected(self):
109+
with patch.object(
110+
_mod.socket,
111+
"gethostbyname",
112+
side_effect=socket.gaierror("Name or service not known"),
113+
):
114+
with pytest.raises(ValueError, match="Cannot resolve"):
115+
validate_image_url("http://nonexistent.invalid/img.jpg")

0 commit comments

Comments
 (0)