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
203 lines
7.6 KiB
Python
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
|