chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user