Files
nvidia--skillspector/tests/unit/test_input_handler_ssrf.py
wehub-resource-sync 2114ccd278
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
chore: import upstream snapshot with attribution
2026-07-13 12:23:39 +08:00

203 lines
7.6 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for SSRF protection and zip-slip prevention in input_handler."""
from __future__ import annotations
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
from skillspector.input_handler import (
ALLOWED_DOWNLOAD_HOSTS,
ALLOWED_GIT_HOSTS,
InputHandler,
_is_private_ip,
)
class TestPrivateIPDetection:
"""_is_private_ip blocks internal network addresses."""
def test_localhost_blocked(self) -> None:
assert _is_private_ip("127.0.0.1") is True
def test_ipv6_loopback_blocked(self) -> None:
assert _is_private_ip("::1") is True
def test_private_10_range_blocked(self) -> None:
assert _is_private_ip("10.0.0.1") is True
def test_private_172_range_blocked(self) -> None:
assert _is_private_ip("172.16.0.1") is True
def test_private_192_range_blocked(self) -> None:
assert _is_private_ip("192.168.1.1") is True
def test_link_local_169_blocked(self) -> None:
assert _is_private_ip("169.254.169.254") is True
def test_public_ip_allowed(self) -> None:
assert _is_private_ip("140.82.121.3") is False
def test_unresolvable_host_blocked(self) -> None:
assert _is_private_ip("definitely-not-a-real-host-xyz123.invalid") is True
class TestGitCloneSSRF:
"""Git clone validates URLs against allowlist and SSRF."""
def test_internal_git_url_blocked(self) -> None:
handler = InputHandler()
with pytest.raises(ValueError, match="not in the allowed hosts"):
handler._clone_git("https://internal-gitlab.corp.local/repo.git")
handler.cleanup()
def test_localhost_git_url_blocked(self) -> None:
handler = InputHandler()
with pytest.raises(ValueError, match="not in the allowed hosts"):
handler._clone_git("http://127.0.0.1:8080/repo.git")
handler.cleanup()
def test_metadata_endpoint_blocked(self) -> None:
handler = InputHandler()
with pytest.raises(ValueError, match="not in the allowed hosts"):
handler._clone_git("http://169.254.169.254/latest/meta-data/")
handler.cleanup()
@patch("skillspector.input_handler.subprocess.run")
def test_github_url_allowed(self, mock_run) -> None:
mock_run.return_value = None
handler = InputHandler()
handler._clone_git("https://github.com/NVIDIA/SkillSpector.git")
mock_run.assert_called_once()
handler.cleanup()
@patch("skillspector.input_handler.subprocess.run")
def test_gitlab_url_allowed(self, mock_run) -> None:
mock_run.return_value = None
handler = InputHandler()
handler._clone_git("https://gitlab.com/user/repo.git")
mock_run.assert_called_once()
handler.cleanup()
class TestDownloadSSRF:
"""File download validates URLs against allowlist and SSRF."""
def test_internal_url_blocked(self) -> None:
handler = InputHandler()
with pytest.raises(ValueError, match="not in the allowed hosts"):
handler._download_file("http://192.168.1.100/secrets.txt")
handler.cleanup()
def test_cloud_metadata_blocked(self) -> None:
handler = InputHandler()
with pytest.raises(ValueError, match="not in the allowed hosts"):
handler._download_file("http://169.254.169.254/latest/meta-data/iam/")
handler.cleanup()
def test_arbitrary_host_blocked(self) -> None:
handler = InputHandler()
with pytest.raises(ValueError, match="not in the allowed hosts"):
handler._download_file("https://evil-attacker.com/payload.md")
handler.cleanup()
@patch("skillspector.input_handler.httpx.Client")
def test_raw_githubusercontent_allowed(self, mock_client_cls) -> None:
mock_client = mock_client_cls.return_value.__enter__.return_value
mock_response = mock_client.get.return_value
mock_response.content = b"# SKILL.md content"
mock_response.headers = {}
handler = InputHandler()
result = handler._download_file(
"https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/SKILL.md"
)
assert result.is_dir()
handler.cleanup()
@patch("skillspector.input_handler.httpx.Client")
def test_download_does_not_follow_redirects(self, mock_client_cls) -> None:
"""Redirects are disabled to prevent SSRF via open-redirect on allowed hosts."""
mock_client = mock_client_cls.return_value.__enter__.return_value
mock_client.get.return_value.content = b"# content"
mock_client.get.return_value.headers = {}
handler = InputHandler()
try:
handler._download_file(
"https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/SKILL.md"
)
except Exception:
pass
mock_client_cls.assert_called_once_with(follow_redirects=False, timeout=30)
handler.cleanup()
class TestZipSlipPrevention:
"""Zip extraction blocks path traversal attacks."""
def test_zip_slip_blocked(self, tmp_path: Path) -> None:
zip_path = tmp_path / "malicious.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("../../etc/passwd", "root:x:0:0:")
handler = InputHandler()
handler._temp_dir = tmp_path / "work"
handler._temp_dir.mkdir()
with pytest.raises(ValueError, match="zip-slip"):
handler._extract_zip(zip_path)
handler.cleanup()
def test_normal_zip_extracts_fine(self, tmp_path: Path) -> None:
zip_path = tmp_path / "normal.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("skill/SKILL.md", "# Normal skill")
zf.writestr("skill/tool.py", "print('hello')")
handler = InputHandler()
handler._temp_dir = tmp_path / "work"
handler._temp_dir.mkdir()
result = handler._extract_zip(zip_path)
assert result.is_dir()
assert (result / "SKILL.md").exists()
handler.cleanup()
def test_deeply_nested_path_allowed(self, tmp_path: Path) -> None:
zip_path = tmp_path / "deep.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("a/b/c/d/file.txt", "deep content")
handler = InputHandler()
handler._temp_dir = tmp_path / "work"
handler._temp_dir.mkdir()
result = handler._extract_zip(zip_path)
assert result.is_dir()
handler.cleanup()
class TestAllowlistConfiguration:
"""Allowlists contain expected hosts."""
def test_git_hosts_include_major_forges(self) -> None:
assert "github.com" in ALLOWED_GIT_HOSTS
assert "gitlab.com" in ALLOWED_GIT_HOSTS
assert "bitbucket.org" in ALLOWED_GIT_HOSTS
def test_download_hosts_include_raw_github(self) -> None:
assert "raw.githubusercontent.com" in ALLOWED_DOWNLOAD_HOSTS
def test_download_hosts_include_huggingface(self) -> None:
assert "huggingface.co" in ALLOWED_DOWNLOAD_HOSTS