Files
autocad-mcp-pro/tests/test_security.py
T
Umutcan Edizsalan 32a68be919 fix: correctness, cross-backend parity & enforced quality gate (audit Sprints 2-4)
Audit-driven fixes across the dual backend, the premium workflow, and the
engineering layer. 318 tests, ruff-clean.

Quality gate (the moat):
- drawing_finalize now runs the premium ISO-128 critique in addition to the
  8-step validator; leftover construction, non-ISO lineweights, untrimmed
  corners, duplicates and dim overlap block the gate (strict_critique fails on
  warnings too).
- dim_overlap critique no longer a no-op (dim reference point + bbox fallback);
  dim/construction layers resolve from the active layer set (iso13567 too).

Stability / correctness:
- view_screenshot & drawing_export_pdf render headless via Agg (no more
  worker-thread SIGSEGV).
- gear tooth profile no longer self-overlaps at high tooth counts; section view
  no longer duplicates bore lines; validator keyway heuristic fixed.
- ezdxf aligned/angular dim API, full-circle polar array duplicate already
  covered by Sprint 1; this bundle adds save-format-from-extension,
  entity_offset side selection, ARC length, properties parity (bounding_box /
  rotation / char_height), mtext rotation.

COM robustness (mocked-COM tested):
- CoUninitialize on teardown; transaction commit/rollback try/finally + UNDO via
  the CMDACTIVE guard; system_run_lisp guard + USERS1 value capture;
  set_variable type coercion; HWND via Application.HWND; BlockInfo.description.

Security:
- HTTP remote-bind guard fires on every launch path (incl. `fastmcp run`);
  StaticTokenVerifier wired into FastMCP; AutoLISP allowlist bypass-vector tests.

Hygiene: linetype-on-demand in _apply_attrs, honest view_zoom returns, snap-on-arc
sweep handling, point_tangent acos clamp, dynamic tool_groups / tool count,
word-boundary SPUR/HELICAL, undo-stack locking.

Deferred: R22 (undo/transaction stack isolation) lands with the I1 refiner (1.2),
which depends on it.
2026-06-19 08:36:58 +03:00

275 lines
10 KiB
Python

"""Security tests for path validation and command sanitization."""
from __future__ import annotations
import pytest
from fastmcp.exceptions import ToolError
from security import sanitize_command, sanitize_lisp, validate_path
# ---------------------------------------------------------------------------
# Path validation tests
# ---------------------------------------------------------------------------
class TestValidatePath:
"""Tests for validate_path function."""
def test_valid_absolute_path(self, tmp_path):
"""Valid absolute path should resolve correctly."""
test_file = tmp_path / "drawing.dxf"
test_file.touch()
result = validate_path(str(test_file), allow_write=False)
assert result.exists()
def test_empty_path_rejected(self):
"""Empty path should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
validate_path("")
def test_whitespace_path_rejected(self):
"""Whitespace-only path should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
validate_path(" ")
def test_traversal_dot_dot_slash(self):
"""Path with ../ should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("../../../etc/passwd")
def test_traversal_dot_dot_backslash(self):
"""Path with ..\\ should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("..\\..\\Windows\\System32\\cmd.exe")
def test_traversal_encoded(self):
"""URL-encoded traversal should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("%2e%2e/secret")
def test_null_byte(self):
"""Null byte in path should be rejected."""
with pytest.raises(ToolError, match="traversal"):
validate_path("file\x00.dxf")
def test_write_requires_existing_parent(self, tmp_path):
"""Write mode should check parent directory exists."""
nonexistent = tmp_path / "nonexistent_dir" / "file.dxf"
with pytest.raises(ToolError, match="Parent directory"):
validate_path(str(nonexistent), allow_write=True)
def test_write_with_valid_parent(self, tmp_path):
"""Write mode should pass when parent exists."""
target = tmp_path / "output.dxf"
result = validate_path(str(target), allow_write=True)
assert result.parent.exists()
def test_allowed_paths_enforcement(self, tmp_path, monkeypatch):
"""When ALLOWED_PATHS is set, paths outside should be rejected."""
from config import Settings
allowed_dir = tmp_path / "allowed"
allowed_dir.mkdir()
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config
config.settings = Settings()
blocked = tmp_path / "blocked" / "file.dxf"
blocked.parent.mkdir(exist_ok=True)
blocked.touch()
with pytest.raises(ToolError, match="not inside any allowed"):
validate_path(str(blocked))
config.settings = Settings.__new__(Settings)
config.settings.allowed_paths = []
config.settings.dangerous_commands_enabled = False
monkeypatch.delenv("ALLOWED_PATHS", raising=False)
config.settings = Settings()
def test_allowed_paths_permits_inside(self, tmp_path, monkeypatch):
"""When ALLOWED_PATHS is set, paths inside should be allowed."""
from config import Settings
allowed_dir = tmp_path / "allowed"
allowed_dir.mkdir()
test_file = allowed_dir / "drawing.dxf"
test_file.touch()
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config
config.settings = Settings()
result = validate_path(str(test_file))
assert result.exists()
monkeypatch.delenv("ALLOWED_PATHS", raising=False)
config.settings = Settings()
# ---------------------------------------------------------------------------
# Command sanitization tests
# ---------------------------------------------------------------------------
class TestSanitizeCommand:
"""Tests for sanitize_command function."""
def test_safe_command_passes(self):
"""Safe commands should pass through."""
assert sanitize_command("_ZOOM E") == "_ZOOM E"
def test_safe_regen_command(self):
"""REGEN command should pass."""
assert sanitize_command("_REGEN") == "_REGEN"
def test_empty_command_rejected(self):
"""Empty command should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
sanitize_command("")
def test_erase_command_blocked(self):
"""ERASE command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("ERASE ALL")
def test_delete_command_blocked(self):
"""DELETE command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("DELETE")
def test_shell_command_blocked(self):
"""SHELL command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("SHELL")
def test_quit_command_blocked(self):
"""QUIT command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("QUIT")
def test_script_command_blocked(self):
"""SCRIPT command should be blocked."""
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("SCRIPT malicious.scr")
def test_dangerous_enabled_bypass(self, monkeypatch):
"""When DANGEROUS_COMMANDS_ENABLED=true, all commands pass."""
from config import Settings
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config
config.settings = Settings()
result = sanitize_command("ERASE ALL")
assert result == "ERASE ALL"
monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False)
config.settings = Settings()
# ---------------------------------------------------------------------------
# LISP sanitization tests
# ---------------------------------------------------------------------------
class TestSanitizeLisp:
"""Tests for sanitize_lisp function."""
def test_safe_lisp_passes(self):
"""Safe LISP expressions should pass."""
assert sanitize_lisp('(setvar "DIMSCALE" 1.0)') == '(setvar "DIMSCALE" 1.0)'
def test_safe_arithmetic(self):
"""Simple arithmetic LISP should pass."""
assert sanitize_lisp("(+ 1 2 3)") == "(+ 1 2 3)"
def test_empty_expression_rejected(self):
"""Empty expression should raise ToolError."""
with pytest.raises(ToolError, match="cannot be empty"):
sanitize_lisp("")
def test_startapp_blocked(self):
"""startapp should be blocked (arbitrary program execution)."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(startapp "cmd")')
def test_command_function_blocked(self):
"""(command ...) should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(command "ERASE" "ALL" "")')
def test_file_delete_blocked(self):
"""vl-file-delete should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(vl-file-delete "C:/important.dwg")')
def test_load_blocked(self):
"""(load ...) should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(load "malicious.lsp")')
def test_eval_blocked(self):
"""(eval ...) should be blocked."""
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp("(eval (read user-input))")
def test_dangerous_enabled_bypass(self, monkeypatch):
"""When DANGEROUS_COMMANDS_ENABLED=true, all LISP passes."""
from config import Settings
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config
config.settings = Settings()
result = sanitize_lisp('(startapp "cmd")')
assert result == '(startapp "cmd")'
monkeypatch.delenv("DANGEROUS_COMMANDS_ENABLED", raising=False)
config.settings = Settings()
class TestSanitizerBypassVectors:
"""R8 — adversarial coverage: the deny-list must hold against common evasion
tricks. Each asserts the guard REJECTS the payload (defensive verification),
plus that legitimate input is NOT over-blocked."""
def test_command_newline_injected_second_command(self):
# A safe head command with a dangerous one smuggled after a newline.
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("_ZOOM E\n_ERASE")
def test_command_case_insensitive(self):
with pytest.raises(ToolError, match="restricted command"):
sanitize_command("eRaSe all")
def test_command_legitimate_not_overblocked(self):
assert sanitize_command("_LINE 0,0 10,10") == "_LINE 0,0 10,10"
def test_lisp_symbol_aliasing_blocked(self):
# (setq f command) references `command` as a value to dodge head-only
# matching — the bare-token scan must still catch it.
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp("(setq f command)(f)")
def test_lisp_activex_family_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(vlax-invoke obj "SendCommand" "x")')
def test_lisp_express_tools_shell_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(acet-sys-shell "cmd")')
def test_lisp_custom_command_invocation_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp("(c:mycmd)")
def test_lisp_dangerous_token_inside_wrapper_blocked(self):
with pytest.raises(ToolError, match="restricted function"):
sanitize_lisp('(startapp (strcat "c" "md"))')
def test_lisp_legitimate_not_overblocked(self):
assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))"