Skip to main content
Glama
test_security.py13.7 kB
import tempfile from pathlib import Path import pytest from hooks_mcp.config import Action, ActionParameter, ParameterType from hooks_mcp.executor import CommandExecutor, ExecutionError from hooks_mcp.utils import validate_project_path class TestSecurity: """Test security features of HooksMCP.""" @pytest.fixture(autouse=True) def setup_teardown(self): """Set up and tear down test environment.""" # Create a temporary directory for testing self.temp_dir = tempfile.TemporaryDirectory() self.project_root = Path(self.temp_dir.name) # Create some test files and directories (self.project_root / "tests").mkdir() (self.project_root / "tests" / "test1.py").touch() (self.project_root / "src").mkdir() (self.project_root / "src" / "main.py").touch() self.executor = CommandExecutor() self.executor.project_root = self.project_root # This will run after each test yield # Clean up test environment self.temp_dir.cleanup() def test_validate_project_path_safe_paths(self): """Test that safe paths are validated correctly.""" # Test valid paths assert validate_project_path("./tests/test1.py", self.project_root) assert validate_project_path("tests/test1.py", self.project_root) assert validate_project_path("./src/main.py", self.project_root) assert validate_project_path("src/main.py", self.project_root) assert validate_project_path("./", self.project_root) assert validate_project_path(".", self.project_root) @pytest.mark.parametrize( "safe_path", [ # Basic relative paths "tests/test1.py", "src/main.py", "./tests/test1.py", "./src/main.py", # Current directory references ".", "./", "./.", # Valid subdirectory paths "tests/subdir/file.py", "src/package/module.py", "./tests/deep/nested/file.py", "src/very/deep/nested/structure/file.py", # Non-existent but valid paths "tests/nonexistent.py", "src/future_file.py", "new_directory/new_file.py", "./future/directory/file.py", # Files with various extensions "README.md", "package.json", "Dockerfile", "script.sh", "config.yaml", "data.json", "style.css", "index.html", # Files with special characters (but still safe) "file-with-dashes.py", "file_with_underscores.py", "file.with.dots.py", "file with spaces.py", "tests/test-file.py", "src/module_name.py", # Empty string (should resolve to project root) "", # Paths with current directory components (but still within project) "tests/./test1.py", "./tests/./test1.py", "tests/./subdir/./file.py", "src/./package/./module.py", # Multiple slashes (should be normalized) "tests//test1.py", "./tests//subdir//file.py", "src///main.py", # Paths that look suspicious but are actually safe "tests/test_security.py", "src/path_utils.py", "config/environment.py", "scripts/home_manager.py", # Deep nested paths "a/b/c/d/e/f/g/file.py", "./very/deep/path/structure/file.txt", # Files that start with dots (hidden files) ".gitignore", ".env", ".config/settings.json", "tests/.test_config", # Numeric and mixed names "file123.py", "123file.py", "v1.2.3/release.py", "test_2024_01_01.py", ], ) def test_validate_project_path_comprehensive_safe_paths(self, safe_path): """Test that various legitimate path patterns are all accepted.""" assert validate_project_path(safe_path, self.project_root), ( f"Legitimate path '{safe_path}' was incorrectly rejected" ) def test_validate_project_path_dangerous_paths(self): """Test that dangerous paths are rejected.""" # Test paths that break out of project root assert not validate_project_path("../outside.py", self.project_root) assert not validate_project_path("~/secret.py", self.project_root) assert not validate_project_path("$HOME/secret.py", self.project_root) assert not validate_project_path("./../outside.py", self.project_root) assert not validate_project_path("tests/../../outside.py", self.project_root) @pytest.mark.parametrize( "malicious_path", [ # Directory traversal attacks - basic "../outside.py", "../../outside.py", "../../../etc/passwd", "../../../../etc/passwd", # Directory traversal attacks - with current directory "./../outside.py", "./../../outside.py", "./../../../etc/passwd", # Directory traversal attacks - from subdirectories that actually escape "tests/../../outside.py", "tests/../../../etc/passwd", "tests/./../../outside.py", "src/../../outside.py", "tests/../src/../../outside.py", "tests/./../../src/../outside.py", # Home directory expansion attacks "~/secret.py", "~/../../etc/passwd", "~/../etc/passwd", "~root/secret.py", "~/../../../etc/passwd", # Environment variable expansion attacks "$HOME/secret.py", "${HOME}/secret.py", "$HOME/../etc/passwd", "$HOME/../../etc/passwd", "$SHELL/../etc/passwd", "$PATH/../etc/passwd", "$PWD/../outside.py", "${PWD}/../outside.py", "$TMPDIR/../etc/passwd", # Absolute path attacks "/etc/passwd", "/tmp/outside.py", "/absolute/path/outside.py", "/usr/bin/evil", "/var/log/outside.py", "/root/secret.py", # Mixed expansion attacks "~/$USER/../etc/passwd", "$HOME/../../../etc/passwd", "~/../$HOME/secret.py", "${HOME}/../$USER/secret.py", "~/../${PWD}/outside.py", "$HOME/../~root/secret.py", # Edge cases with special characters that escape "../outside file.py", "~/ secret.py", "$HOME/../file with spaces.py", # Trying to escape with multiple techniques "../~root/secret.py", "~/../$HOME/../etc/passwd", "$HOME/../~/../etc/passwd", "${HOME}/../../../etc/passwd", "~/../${HOME}/../etc/passwd", # Network paths (Windows style, but should be rejected on any platform) "//network/share/file.py", # Null byte attacks (if they don't cause exceptions) "../outside.py\x00.txt", "~/secret.py\x00.txt", # Unicode normalization attacks "../\u00e9outside.py", # é character "~\u002f../etc/passwd", # Unicode slash # Case variations (especially important on case-insensitive filesystems) "../Outside.py", "~/Secret.py", "$HOME/../Etc/Passwd", # Long paths trying to exhaust path resolution "../" * 20 + "etc/passwd", "tests/" + "../" * 10 + "outside.py", # Symlink-style attacks (even without actual symlinks) that actually escape "symlink/../../../etc/passwd", ], ) def test_validate_project_path_comprehensive_attacks(self, malicious_path): """Test that various malicious path attack vectors are all rejected.""" assert not validate_project_path(malicious_path, self.project_root), ( f"Security vulnerability: malicious path '{malicious_path}' was incorrectly accepted" ) @pytest.mark.parametrize( "tricky_but_safe_path", [ # These look suspicious but actually resolve to within the project root "tests/../outside.py", # resolves to "outside.py" "src/../config.json", # resolves to "config.json" "tests/../src/../main.py", # resolves to "main.py" "./tests/../src/../app.py", # resolves to "app.py" "docs/../outside.py", # resolves to "outside.py" "tests/./../../tests/test1.py", # resolves to "tests/test1.py" (back to same place) # Environment variables that resolve to relative paths within project # Note: These depend on the specific environment, so might vary # Whitespace that doesn't change the meaning " tests/../outside.py", # leading space resolves to " tests/../outside.py" -> may be within project "tests/../outside.py ", # trailing space "\ttests/../main.py", # tab prefix "\ntests/../config.py", # newline prefix # Multiple slashes that normalize "tests//../outside.py", # double slash normalizes to single "tests///../outside.py", # triple slash normalizes ".//outside.py", # current dir with double slash # Complex but safe navigation "tests/subdir/../../outside.py", # goes up from subdir but stays in project "src/lib/../../../main.py", # complex navigation that ends up in project # Shell patterns that don't actually get expanded by our function "file$(echo nothing).py", # shell command substitution (not expanded by os.path.expandvars) "file`echo nothing`.py", # backtick substitution (not expanded) "${VAR:-default.py}", # bash parameter expansion (partially expanded) ], ) def test_validate_project_path_tricky_but_safe(self, tricky_but_safe_path): """Test that paths which look suspicious but are actually safe are accepted.""" # Note: Some of these might actually be rejected depending on environment # The goal is to document the expected behavior for tricky edge cases result = validate_project_path(tricky_but_safe_path, self.project_root) print(f"Path '{tricky_but_safe_path}' -> {'ALLOWED' if result else 'REJECTED'}") # We're not asserting here because behavior may vary by environment # This test is mainly for documentation and manual inspection def test_validate_project_path_nonexistent_paths(self): """Test that nonexistent paths are still validated correctly.""" # Nonexistent paths should still pass validation if they're within project boundaries assert validate_project_path("./tests/nonexistent.py", self.project_root) assert validate_project_path("tests/nonexistent.py", self.project_root) # But dangerous nonexistent paths should still be rejected assert not validate_project_path("../nonexistent.py", self.project_root) def test_command_executor_safe_path(self): """Test that command executor accepts safe paths.""" action = Action( name="test_action", description="Test action", command="echo Testing", parameters=[ ActionParameter( "file_path", ParameterType.PROJECT_FILE_PATH, "A file path", "tests/test1.py", ) ], ) # This should not raise an exception result = self.executor.execute_action(action, {"file_path": "tests/test1.py"}) assert "Testing" in result["stdout"] def test_command_executor_dangerous_path(self): """Test that command executor rejects dangerous paths.""" action = Action( name="test_action", description="Test action", command="echo Testing", parameters=[ ActionParameter( "file_path", ParameterType.PROJECT_FILE_PATH, "A file path" ) ], ) # These should raise ExecutionError with pytest.raises(ExecutionError): self.executor.execute_action(action, {"file_path": "../outside.py"}) with pytest.raises(ExecutionError): self.executor.execute_action(action, {"file_path": "~/secret.py"}) with pytest.raises(ExecutionError): self.executor.execute_action(action, {"file_path": "$HOME/secret.py"}) def test_command_executor_insecure_string(self): """Test that insecure strings are passed through without validation.""" action = Action( name="test_action", description="Test action", command="echo $TEST_STRING", parameters=[ ActionParameter( "TEST_STRING", ParameterType.INSECURE_STRING, "An insecure string" ) ], ) # This should work even with potentially dangerous content # (though the command itself won't do anything harmful) result = self.executor.execute_action(action, {"TEST_STRING": "123 && rm -r ~"}) assert "123 && rm -r ~" in result["stdout"]

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/scosman/actions_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server