mcp-forge/tests/security/test_allowlist.py
2026-02-07 07:45:57 +01:00

486 lines
17 KiB
Python

"""
Tests for Podman operation allowlist and validation.
Following TDD approach - these tests are written before implementation.
Tests cover all security validation requirements from todo.md section 1.2.2.
"""
import pytest
def test_allowed_operation_with_valid_params_passes():
"""Test that allowed operation with valid parameters passes validation."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(
audit_log=Path("/var/log/audit.log"),
enforce_resource_limits=True,
allow_network=False
)
validator = OperationValidator(config)
# Should not raise
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000",
"memory": "536870912",
"cpu_quota": 50000
},
session_id="test-session-123"
)
def test_allowed_operation_with_forbidden_params_raises_security_error():
"""Test that allowed operation with forbidden parameters raises SecurityError."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(
audit_log=Path("/var/log/audit.log"),
enforce_resource_limits=True,
allow_network=False
)
validator = OperationValidator(config)
# Try to add privileged mode
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"privileged": True, # FORBIDDEN
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session-123"
)
assert "privileged" in str(exc_info.value).lower()
def test_forbidden_param_cap_add_raises_security_error():
"""Test that cap_add parameter is rejected."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"cap_add": ["SYS_ADMIN"], # FORBIDDEN
"network_mode": "none",
"read_only": True
},
session_id="test-session"
)
assert "cap_add" in str(exc_info.value).lower()
def test_forbidden_param_devices_raises_security_error():
"""Test that devices parameter is rejected."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"devices": ["/dev/sda"], # FORBIDDEN
"network_mode": "none",
"read_only": True
},
session_id="test-session"
)
assert "devices" in str(exc_info.value).lower()
def test_required_parameters_validation():
"""Test that required parameters are enforced."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Missing network_mode
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "network_mode" in str(exc_info.value).lower()
# Missing read_only
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "none",
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "read_only" in str(exc_info.value).lower()
def test_image_allowlist_enforcement():
"""Test that only allowed images can be used."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Valid image
validator.validate_image_name("mcp-forge/python:3.11")
validator.validate_image_name("mcp-forge/python:3.12")
validator.validate_image_name("mcp-forge/jupyter:latest")
validator.validate_image_name("mcp-forge/custom:my-env")
# Invalid image
with pytest.raises(SecurityError) as exc_info:
validator.validate_image_name("ubuntu:latest")
assert "image" in str(exc_info.value).lower() or "allowlist" in str(exc_info.value).lower()
with pytest.raises(SecurityError):
validator.validate_image_name("malicious/image:latest")
def test_volume_mount_path_validation():
"""Test that volume mount paths are validated against allowlist."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Valid session path
validator.validate_volume_mount(
"/mcp-forge/sessions/test-session-123/workdir",
"test-session-123"
)
# Valid shared readonly path
validator.validate_volume_mount(
"/mcp-forge/shared/readonly/data",
"test-session-123"
)
# Valid upload path
validator.validate_volume_mount(
"/mcp-forge/uploads/test-session-123/file.txt",
"test-session-123"
)
# Invalid: root path
with pytest.raises(SecurityError) as exc_info:
validator.validate_volume_mount("/", "test-session")
assert "forbidden" in str(exc_info.value).lower() or "root" in str(exc_info.value).lower()
# Invalid: /etc
with pytest.raises(SecurityError):
validator.validate_volume_mount("/etc/passwd", "test-session")
# Invalid: docker socket
with pytest.raises(SecurityError):
validator.validate_volume_mount("/var/run/docker.sock", "test-session")
# Invalid: podman socket
with pytest.raises(SecurityError):
validator.validate_volume_mount("/var/run/podman/podman.sock", "test-session")
def test_capability_restrictions():
"""Test that capability restrictions are enforced."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# cap_add is forbidden
with pytest.raises(SecurityError):
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"cap_add": ["NET_ADMIN"],
"network_mode": "none",
"read_only": True
},
session_id="test-session"
)
def test_network_mode_enforcement():
"""Test that network mode is enforced as 'none'."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Wrong network mode
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "bridge", # Must be "none"
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "network_mode" in str(exc_info.value).lower()
def test_privileged_mode_always_rejected():
"""Test that privileged mode is always rejected regardless of other params."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"privileged": True,
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "privileged" in str(exc_info.value).lower()
def test_session_container_tracking():
"""Test that session containers are tracked and validated."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Register a session container
validator.register_session_container("container-123")
# Should be able to operate on registered container
validator.validate_container_start("container-123")
validator.validate_container_stop("container-123")
validator.validate_container_remove("container-123")
# Cannot operate on non-session container
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_start("unknown-container")
assert "session" in str(exc_info.value).lower() or "not found" in str(exc_info.value).lower()
with pytest.raises(SecurityError):
validator.validate_container_stop("unknown-container")
with pytest.raises(SecurityError):
validator.validate_container_remove("unknown-container")
def test_unregister_session_container():
"""Test that session containers can be unregistered."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
validator.register_session_container("container-123")
validator.validate_container_start("container-123") # Should work
validator.unregister_session_container("container-123")
# After unregistration, should not work
with pytest.raises(Exception): # SecurityError
validator.validate_container_start("container-123")
def test_validate_container_create_stores_container_id():
"""Test that validate_container_create automatically registers container."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Create container with session_id should auto-register
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session-123"
)
# The actual container_id would be returned by Podman after creation
# So this test just verifies validation passes
def test_security_error_includes_rule_violation():
"""Test that SecurityError messages indicate what rule was violated."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Test various violations have clear messages
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={"privileged": True},
session_id="test"
)
error_msg = str(exc_info.value)
assert "privileged" in error_msg.lower()
with pytest.raises(SecurityError) as exc_info:
validator.validate_image_name("bad-image:latest")
error_msg = str(exc_info.value)
assert "image" in error_msg.lower() or "allowlist" in error_msg.lower()
def test_wildcard_image_pattern_matching():
"""Test that wildcard patterns work in image allowlist."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# mcp-forge/custom:* should match any tag
validator.validate_image_name("mcp-forge/custom:my-env-v1")
validator.validate_image_name("mcp-forge/custom:another-tag")
validator.validate_image_name("mcp-forge/custom:abc123")
def test_forbidden_mount_paths_comprehensive():
"""Test all forbidden mount paths are blocked."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
forbidden_paths = [
"/",
"/etc",
"/etc/shadow",
"/var/run/docker.sock",
"/var/run/podman/podman.sock",
"/sys",
"/sys/kernel",
"/proc",
"/proc/self",
]
for path in forbidden_paths:
with pytest.raises(SecurityError):
validator.validate_volume_mount(path, "test-session")
def test_pid_mode_forbidden():
"""Test that pid_mode parameter is forbidden."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError):
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"pid_mode": "host", # FORBIDDEN
"network_mode": "none"
},
session_id="test"
)
def test_ipc_mode_forbidden():
"""Test that ipc_mode parameter is forbidden."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError):
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"ipc_mode": "host", # FORBIDDEN
"network_mode": "none"
},
session_id="test"
)
def test_session_path_must_match_session_id():
"""Test that session paths must match the provided session_id."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Correct session match
validator.validate_volume_mount(
"/mcp-forge/sessions/session-123/workdir",
"session-123"
)
# Wrong session in path
with pytest.raises(SecurityError):
validator.validate_volume_mount(
"/mcp-forge/sessions/other-session/workdir",
"session-123"
)