Files
tekton/addons/godot_ai/utils/path_validator.gd
T

172 lines
8.2 KiB
GDScript

@tool
class_name McpPathValidator
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Validates `res://`-rooted paths against directory-traversal escape.
##
## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd`
## because the only check was `path.begins_with("res://")`. LLM-driven path
## generation (prompt injection, agent typos, untrusted issue/PR text in
## context) can produce traversal payloads for the write tools that produce
## arbitrary disk content (`script_create`, `filesystem_write_text`,
## `patch_script`) and for the matching reads (info disclosure surface).
##
## Two entry points:
## * `validate_resource_path` — for paths that name a `res://` disk file the
## plugin will read or (with `for_write`) write. This is the strict one.
## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which
## also accepts `uid://` (an opaque resource-DB id that cannot express
## traversal) and `user://` (the per-project user data sandbox). Load
## handlers must use this so `uid://` references copied out of `.tscn`
## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading.
##
## Error wrapping: callers should use `path_error` / `loadable_error`, which
## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A
## bad path is a value-domain error, and funneling every site through one
## wrapper keeps the error code consistent across all handlers.
##
## Known limitation: containment is lexical (`globalize_path` + `simplify_path`
## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath.
## A symlink *inside* the project that points outside it can therefore defeat
## the under-root check. This matches the engine's own `res://` resolution and
## is accepted; the loopback trust boundary is the primary control.
# Cached project / user roots. `globalize_path` is stable across the editor's
# lifetime — caching avoids redundant resolution on every call. Matters most
# for `reimport`, which loops the validator over each path in a batch.
# Lazy-init on first call so static-load timing can't see a half-initialised
# ProjectSettings.
static var _cached_res_root: String = ""
static var _cached_user_root: String = ""
static func _res_root() -> String:
if _cached_res_root.is_empty():
_cached_res_root = ProjectSettings.globalize_path("res://").simplify_path()
return _cached_res_root
static func _user_root() -> String:
if _cached_user_root.is_empty():
_cached_user_root = ProjectSettings.globalize_path("user://").simplify_path()
return _cached_user_root
## Returns "" when the path is a safe `res://`-rooted reference inside the
## project root. Returns a human-readable error message otherwise.
## Prefer `path_error` over calling this directly — it wraps the message in the
## canonical error code.
##
## Pass `for_write = true` for any handler that creates/overwrites the file
## (write_file, create_script, patch_script, ResourceSaver-backed saves,
## scene saves). Write callers additionally refuse the project manifest and
## startup override, plus the `.godot/` metadata dir. Reads default to
## `for_write = false`, which permits inspecting those files.
static func validate_resource_path(path: String, for_write: bool = false) -> String:
if path.is_empty():
return "Missing required param: path"
## Guard the sentinel: on builds where String.chr(0) yields "" (some engines
## normalize embedded nulls away, e.g. 4.3), contains("") would be true and
## reject every path. A String that can't hold a null can't smuggle one.
var nul := String.chr(0)
if not nul.is_empty() and path.contains(nul):
return "Path must not contain null bytes"
if not path.begins_with("res://"):
return "Path must start with res://"
var confine_err := _confine_under(path, _res_root(), "res://")
if not confine_err.is_empty():
return confine_err
if for_write:
return _reject_sensitive_write(path)
return ""
## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`.
## Accepts, in addition to confined `res://` paths:
## * `uid://<id>` — an opaque 64-bit resource id; it cannot express a path
## and the engine only ever resolves it to a resource already in the
## project, so there is nothing to confine.
## * `user://…` — the per-project user data dir, confined under its root the
## same way `res://` is (so `user://../…` can't escape the sandbox).
static func validate_loadable_path(path: String) -> String:
if path.is_empty():
return "Missing required param: path"
## Guard the sentinel: on builds where String.chr(0) yields "" (some engines
## normalize embedded nulls away, e.g. 4.3), contains("") would be true and
## reject every path. A String that can't hold a null can't smuggle one.
var nul := String.chr(0)
if not nul.is_empty() and path.contains(nul):
return "Path must not contain null bytes"
if path.begins_with("uid://"):
return ""
if path.begins_with("user://"):
return _confine_under(path, _user_root(), "user://")
if path.begins_with("res://"):
return _confine_under(path, _res_root(), "res://")
return "Path must start with res://, uid://, or user://"
## Shared traversal + under-root containment. `root` must already be simplified.
static func _confine_under(path: String, root: String, label: String) -> String:
if ".." in path:
return "Path must not contain '..' (path traversal not allowed)"
var globalized := ProjectSettings.globalize_path(path).simplify_path()
# Append a separator so `/proj_evil/...` can't pretend to be inside `/proj`
# via prefix match. `globalized == root` covers the bare `res://` / `user://`.
if globalized != root and not globalized.begins_with(root + "/"):
return "Path must resolve under %s root" % label
return ""
## Refuse writes that would clobber project-critical files. The path is already
## confirmed `res://`-rooted and traversal-free by the caller.
##
## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are
## case-insensitive by default, so `res://Project.godot` resolves to the real
## `project.godot` and must be refused too.
##
## `.import` sidecars are deliberately NOT blocked — editing an asset's import
## options then re-importing is a legitimate, recoverable workflow (the file is
## source-controlled). The blocked set is the startup-execution surface only:
## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir.
static func _reject_sensitive_write(path: String) -> String:
var file_lower := path.get_file().to_lower()
if file_lower == "project.godot":
return "Refusing to write res://project.godot (project manifest)"
if file_lower == "override.cfg":
return "Refusing to write res://override.cfg (startup config override)"
# Reject the `.godot/` editor-metadata dir at any depth. Split drops empty
# segments so a trailing slash can't hide a segment from the check.
for segment in path.trim_prefix("res://").split("/", false):
if segment.to_lower() == ".godot":
return "Refusing to write under res://.godot/ (editor metadata)"
return ""
## Validate a write/read `res://` path and return a ready error dict, or null
## when the path is fine. The single wrapper every handler should use so the
## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays
## consistent. `param_name` is prefixed onto the message for context.
static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var err := validate_resource_path(path, for_write)
if err.is_empty():
return null
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
## Same as `path_error` but for paths handed to `ResourceLoader` (allows
## `uid://` / `user://`). Returns a ready error dict or null. An empty path is
## reported as MISSING_REQUIRED_PARAM rather than a value error.
static func loadable_error(path: String, param_name: String = "path") -> Variant:
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
var err := validate_loadable_path(path)
if err.is_empty():
return null
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])