"""Security utilities for AutoCAD MCP Pro. Path traversal protection and command injection prevention. """ from __future__ import annotations import logging import os import re from pathlib import Path from fastmcp.exceptions import ToolError import config log = logging.getLogger("autocad_mcp.security") # --------------------------------------------------------------------------- # Path validation # --------------------------------------------------------------------------- _BLOCKED_PATH_PATTERNS = re.compile( r"(\.\.[\\/])" r"|(%2e%2e)" r"|(\\\\[?.]\\)" r"|(^\\\\)" r"|(\x00)", re.IGNORECASE, ) _DRIVE_ANCHOR = re.compile(r"^[A-Za-z]:$") def _reject_non_local_anchor(original: str, resolved: Path) -> None: r"""Every guard below compares against plain ``X:\`` paths. Windows accepts three other anchors that name the same files but compare unequal: the extended-length prefix (``\\?\C:\...``), UNC (``\\host\share``) and the admin shares (``\\localhost\C$``). The backslash spellings are already rejected by ``_BLOCKED_PATH_PATTERNS``, but ``pathlib`` normalises the *forward-slash* spellings into them only after ``.resolve()`` — so they slipped past the pattern check and then failed to match ``C:/Windows`` in the system-directory loop. Proven: ``//?/C:/Windows/win.ini`` handed back a working handle to ``C:\Windows\win.ini``. A hard input rejection, so a plain ``ToolError`` like its siblings — not a capability refusal, which means "this backend cannot" and would wrongly suggest another engine would accept it. """ drive = resolved.drive if _DRIVE_ANCHOR.match(drive): return log.warning("Blocked non-local path anchor: %s -> %s", original, resolved) raise ToolError( f"Path rejected: '{original}' resolves to '{resolved}', which is not a " f"plain local drive path (anchor '{drive}'). Extended-length (\\\\?\\), " "device and UNC (\\\\host\\share, \\\\localhost\\C$) paths bypass the " "system-directory and allowed-root checks and are not accepted. Use a " "normal 'X:\\folder\\file.dxf' path." ) def validate_path(path: str, *, allow_write: bool = False) -> Path: """Resolve and validate a file path against security rules. Raises ToolError if: - Path contains traversal sequences (..) - Path is outside ALLOWED_PATHS (when configured) - Path targets a system directory """ if not path or not path.strip(): raise ToolError("File path cannot be empty.") if _BLOCKED_PATH_PATTERNS.search(path): log.warning("Blocked path traversal attempt: %s", path) raise ToolError( f"Path rejected: suspicious traversal pattern detected in '{path}'. " "Use absolute paths without '..' components." ) resolved = Path(path).resolve() # After .resolve() (drive is only meaningful once resolved) and before the # system-directory loop, whose comparisons this is protecting. POSIX has no # drive component at all, so the check is Windows-only by construction. if os.name == "nt": _reject_non_local_anchor(path, resolved) _SYSTEM_DIRS = [ Path("C:/Windows").resolve(), Path("C:/Program Files").resolve(), Path("C:/Program Files (x86)").resolve(), Path("/etc").resolve(), Path("/usr").resolve(), Path("/bin").resolve(), Path("/sbin").resolve(), Path("/boot").resolve(), Path("/sys").resolve(), Path("/proc").resolve(), ] for sys_dir in _SYSTEM_DIRS: try: if resolved.is_relative_to(sys_dir): log.warning("Blocked system directory access: %s", resolved) raise ToolError( f"Path rejected: '{resolved}' is inside a system directory. " "File operations in system directories are not allowed." ) except (ValueError, TypeError): pass if config.settings.allowed_paths: allowed = False for allowed_dir in config.settings.allowed_paths: try: if resolved.is_relative_to(allowed_dir): allowed = True break except (ValueError, TypeError): pass if not allowed: allowed_str = ", ".join(str(p) for p in config.settings.allowed_paths) log.warning("Path outside allowed directories: %s", resolved) raise ToolError( f"Path rejected: '{resolved}' is not inside any allowed directory. " f"Allowed directories: {allowed_str}. " "Set ALLOWED_PATHS environment variable to configure." ) if allow_write: parent = resolved.parent if not parent.exists(): raise ToolError( f"Parent directory does not exist: '{parent}'. " "Create the directory first or use a valid path." ) return resolved # --------------------------------------------------------------------------- # Command / LISP sanitization # # Why it is NOT incoherent to block INSERT/SAVEAS/NEW/OPEN/PURGE/ERASE here # while `block_insert` / `drawing_save_as` / `drawing_new` / `drawing_open` / # `drawing_purge` / `entity_delete` are tools — this gets re-raised, so it is # recorded once: # # `sanitize_command` and `sanitize_lisp` each have exactly ONE caller, both of # them the COM-only free-text escape hatch into SendCommand. The policy is # scoped to the CHANNEL, not to the operation, which is capability narrowing # rather than a contradiction. # # That "the CHANNEL" was singular was wrong, and it took an audit to notice. # `_ensure_linetype_loaded` and `ComBackend.linetype_load` reach SendCommand # too, carrying a caller's linetype name and file path, and neither passed # through anything here — so the denylist below was guarding one door of two. # `sanitize_symbol_name` / `sanitize_macro_argument` at the end of this module # close the other. Before adding a third SendCommand site, guard it there and # amend this paragraph rather than this one: # # * `block_insert(name, x, y, ...)` takes a block ALREADY in the drawing plus # typed geometry. The INSERT *command* takes a file path and pulls in an # external DWG, which can carry an attached VBA/LISP payload. Same name, # different capability. # * `entity_delete(handle)` deletes one named entity. `ERASE ALL` does not. # * `drawing_save_as` / `_open` / `_new` route their path through # validate_path. A free-text `SAVEAS C:\...` routes through nothing. # --------------------------------------------------------------------------- # Matched against the extracted VERB, so the "_XXX" duplicates the old list # carried are gone — command_verbs() strips every decorator AutoCAD accepts. # # Single-letter aliases are deliberately absent. `E` is the ERASE alias, but it # is also the Extents *option* of ZOOM, so blocking it refuses `_ZOOM\nE\n` — # the canonical zoom-extents macro, and this repo's own safe-command fixture. # A guardrail that blocks the most common harmless macro gets switched off, and # then it guards nothing. _DANGEROUS_COMMANDS = frozenset( { # destructive to the drawing "ERASE", "DELETE", "PURGE", "WBLOCK", "INSERT", "XREF", # ends or overwrites the session / file "QUIT", "EXIT", "CLOSE", "CLOSEALL", "QSAVE", "SAVE", "SAVEAS", "NEW", "OPEN", "RECOVER", "RECOVERALL", # loads or runs code — every channel found while probing "SHELL", "SCRIPT", "SCR", "APPLOAD", "NETLOAD", "ARX", "VBARUN", "VBALOAD", "VBASTMT", "VBAIDE", "VBAMAN", "CUILOAD", "MENULOAD", "MENU", "CUI", # writes files outside the tool layer's path validation "EXPORT", "ETRANSMIT", "PUBLISH", "SECURITYOPTIONS", } ) # AutoCAD accepts a command with any mix of these leading decorators: # _ international (locale-independent) form # . suppress a redefined command # - the command-line (no dialog) variant # ' transparent _COMMAND_DECORATORS = "_.-*'" def command_verbs(command: str) -> list[str]: """Every command verb in a SendCommand string. SendCommand takes a whole macro, not one command: segments are separated by a newline or ``;``, and only the FIRST token of a segment is a verb — the rest is argument data. Scanning the raw string with a word-boundary regex is what refused ``MTEXT 0,0 10,10 Do not erase this part``, which is annotation text, while still missing spellings AutoCAD accepts. """ verbs: list[str] = [] for segment in re.split(r"[\n;]+", command): segment = segment.strip() if not segment: continue token = segment.split()[0].split(",")[0].lstrip(_COMMAND_DECORATORS) if token: verbs.append(token.upper()) return verbs # Dangerous LISP symbols. We match these as **bare tokens anywhere** in the # expression — not only when they appear right after `(` — because aliasing # tricks like `(setq f command)(f "ERASE")` reference the symbol as a value. _DANGEROUS_LISP_PATTERNS = [ # File and shell I/O r"startapp", r"dos_[\w-]*", r"vl-file-(?:delete|rename|copy|directory-p)", r"vl-mkdir", r"vl-directory-files", r"read-line", r"write-line", r"findfile", r"getfiled", # Command execution (every known channel) r"command(?:-s)?", r"vl-cmdf", # ActiveX/COM family — vla-*, vlax-*, vlr-*; covers vla-SendCommand etc. r"vla[xr]?-[\w-]+", # AutoCAD Express Tools — acet-sys-shell and friends r"acet-[\w-]+", # Code loading r"vl-acad-(?:de|un)fun", r"(?:auto)?load", r"arxload", # Indirection vectors that bypass head-only matching r"eval", r"apply", r"funcall", r"function", r"\(\s*quote\s+command\b", # Custom command invocation prefix `(c:my-cmd)` r"c:[\w-]+", # Error handler hijack r"\*push-error-using-command\*", r"\*push-error-using-stack\*", ] # A double-quoted literal is DATA — drawing text, a layer name, a note. The # symbol denylist must not read it. Without this, an engineering CAD server # refused its own vocabulary: '(setq desc "Max load 500 kg")' matched # r"(?:auto)?load" and '(setq layer "C:CENTER")' matched r"c:[\w-]+". _LISP_STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"') _DANGEROUS_LISP_PATTERN = re.compile( r"(?:(? str: """Validate an AutoCAD command string for safety. Raises ToolError if a dangerous command pattern is detected (unless DANGEROUS_COMMANDS_ENABLED is true). """ if not command or not command.strip(): raise ToolError("Command string cannot be empty.") if config.settings.dangerous_commands_enabled: log.info("Dangerous commands enabled — bypassing sanitization for: %s", command[:80]) return command blocked = {verb for verb in command_verbs(command) if verb in _DANGEROUS_COMMANDS} if blocked: log.warning("Blocked dangerous command: %s", command[:120]) raise ToolError( f"Command rejected: {', '.join(sorted(blocked))} " f"{'is a restricted command' if len(blocked) == 1 else 'are restricted commands'}. " "This denylist is a guardrail against accidental destruction, not a " "security boundary — " "prefer the typed tools (entity_delete, drawing_save_as, block_insert, " "drawing_purge), which validate their arguments. Set " "DANGEROUS_COMMANDS_ENABLED=true to allow all commands." ) return command def sanitize_lisp(expression: str) -> str: """Validate an AutoLISP expression for safety. Raises ToolError if a dangerous LISP function is detected (unless DANGEROUS_COMMANDS_ENABLED is true). """ if not expression or not expression.strip(): raise ToolError("LISP expression cannot be empty.") if config.settings.dangerous_commands_enabled: log.info( "Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80] ) return expression # Blank the literals, then scan. The ORIGINAL expression is returned: # this decides whether to run it, it does not rewrite it. code_only = _LISP_STRING_LITERAL.sub('""', expression) if _DANGEROUS_LISP_PATTERN.search(code_only): log.warning("Blocked dangerous LISP expression: %s", expression[:120]) raise ToolError( "LISP expression rejected: contains a restricted function. " "Restricted functions include startapp, command, load, eval, file I/O, etc. " "Set DANGEROUS_COMMANDS_ENABLED=true to allow all LISP expressions." ) return expression # --------------------------------------------------------------------------- # Symbol-table names interpolated into a SendCommand macro # --------------------------------------------------------------------------- # # The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and # `sanitize_lisp` were the only routes into SendCommand. They were not. # `_ensure_linetype_loaded` builds `_-LINETYPE _LOAD {name} {file}` from a # caller's `linetype=` argument, and `ComBackend.linetype_load` adds a # caller-supplied path. SendCommand takes a whole macro and treats a newline # and `;` as segment separators, so either string could append commands that # the 36-verb denylist never sees. # # The guard is the DXF symbol-name rule rather than a second blocklist. AutoCAD # already forbids these characters in a table name, so nothing this rejects was # ever a loadable linetype, and the injection characters fall out of the rule # instead of having to be enumerated and kept up to date. #: Characters DXF forbids in a symbol-table name, plus the whitespace that ends #: a SendCommand segment. _FORBIDDEN_SYMBOL_CHARS = set('<>/\\":;?*|,=`') | set("\n\r\t") #: Long enough for any real linetype, short enough not to be a payload. _MAX_SYMBOL_NAME = 255 def sanitize_symbol_name(name: str, *, kind: str = "symbol") -> str: """Return `name` if it is a legal DXF symbol-table name, else refuse. Used at every point where a caller-controlled name is interpolated into an AutoCAD command macro. The refusal deliberately does not echo the rejected string back: an error message that a client logs or renders is a channel too, and a payload that reaches a log is a payload that reached something. """ candidate = (name or "").strip() if not candidate: raise ToolError(f"{kind} name rejected: empty.") if len(candidate) > _MAX_SYMBOL_NAME: raise ToolError(f"{kind} name rejected: longer than {_MAX_SYMBOL_NAME} characters.") offenders = sorted({c for c in candidate if c in _FORBIDDEN_SYMBOL_CHARS or ord(c) < 32}) if offenders: shown = ", ".join(repr(c) for c in offenders) raise ToolError( f"{kind} name rejected: DXF symbol-table names cannot contain {shown}. " "This name would also end the AutoCAD command macro it is placed in." ) return candidate #: What ends a segment inside a SendCommand macro. A path legitimately contains #: ':' and '\', so a file argument cannot be held to the symbol-name rule -- it #: gets this narrower one plus `validate_path`. _MACRO_SEPARATORS = set("\n\r;") | {chr(c) for c in range(32)} def sanitize_macro_argument(value: str, *, kind: str = "argument") -> str: """Return `value` if it cannot end the AutoCAD command macro it sits in. For arguments that are legitimately allowed the characters DXF forbids in a symbol name -- a file path, mostly. Narrower than `sanitize_symbol_name` on purpose: the goal is only that the string cannot start a second command. """ candidate = (value or "").strip() if not candidate: raise ToolError(f"{kind} rejected: empty.") if any(c in _MACRO_SEPARATORS for c in candidate): raise ToolError( f"{kind} rejected: contains a newline, carriage return, semicolon or " "control character, which would end the AutoCAD command macro it is " "placed in and start another." ) return candidate