Skip to content

Commit 758d821

Browse files
dsarnoclaude
andauthored
[Audit v2 #3] Reject res:// path traversal in script + filesystem write tools (#369)
* Reject res:// path traversal in script + filesystem write tools (#347) Audit v2 finding #3 (#343): script_handler.gd and filesystem_handler.gd validated agent-supplied paths only with `path.begins_with("res://")`, which accepts payloads like `res://../etc/passwd.gd`. LLM-driven path generation (prompt injection, agent typos, untrusted issue/PR text in context) makes this a real escape vector for the only write tools that produce arbitrary disk content. Adds `McpPathValidator.validate_resource_path` with four layered checks: non-empty, `res://` prefix, no `..` substring (cheap defence-in-depth), and globalize-simplify boundary verification against the project root. Migrates the seven prefix-check call sites named in #347: script_handler.gd (create_script, read_script, patch_script, find_symbols) and filesystem_handler.gd (read_file, write_file, reimport). Tests: - McpTestSuite for the validator (happy path, prefix, all four traversal shapes including the exact `res://../etc/passwd.gd` payload from the audit body). - Per-handler regression tests asserting INVALID_PARAMS on the same payload, plus disk-state assertions for the two write tools confirming no file was written outside the project root. - Source-structure pytest pinning the validator's existence, the four required check layers, and the absence of bare `begins_with("res://")` patterns in the affected handlers — drift here would silently weaken the security boundary without any single test failing on a single bad input. https://claude.ai/code/session_01LYwesbn3B7LzLvcV2mPQTV * Cache res_root in McpPathValidator (simplify pass) `ProjectSettings.globalize_path("res://")` is stable across the editor's lifetime — re-resolving it on every validation call (especially inside `reimport`'s per-path loop, where a 100-path batch produced 100x the redundant globalize) is wasted work. Lazy-init a static `_cached_res_root` on first call so static-load timing can't see a half-initialised ProjectSettings. Subsequent calls hit the cache. https://claude.ai/code/session_01LYwesbn3B7LzLvcV2mPQTV * Address Copilot review on PR #369 Three fixes from Copilot's review: 1. test_filesystem.gd / test_script.gd write-traversal tests asserted `FileAccess.file_exists("res://../etc/passwd")` is false. On Unix hosts /etc/passwd already exists, so a regression that let the write through wouldn't matter, but the assertion would *also* fail when Godot's FileAccess globalizes the traversal path through to /etc/* — false-positive failure even when the validator correctly rejected. Switch to a synthetic target name that won't exist in any clean tree. 2. test_path_validator.gd's last test was named `test_rejects_path_resolving_outside_root` but only validated a safe in-root path (the boundary check is unreachable for non-`..` paths, since globalize_path can't escape res:// without a `..` substring, which is rejected by the earlier substring check). Renamed to `test_well_formed_nested_path_passes_boundary_check` so the name matches what the test actually verifies, with a comment explaining why no rejection-side input is asserted on this layer. 3. test_path_traversal_guard.py's structural test claimed to pin "all four layers" but never asserted the non-empty (`is_empty()`) check. Added the missing assertion so the empty-path layer is also drift- protected. Also updated the docstring to drop the misleading "four layers" framing. https://claude.ai/code/session_01LYwesbn3B7LzLvcV2mPQTV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e3a812 commit 758d821

9 files changed

Lines changed: 354 additions & 31 deletions

File tree

plugin/addons/godot_ai/handlers/filesystem_handler.gd

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ extends RefCounted
77
func read_file(params: Dictionary) -> Dictionary:
88
var path: String = params.get("path", "")
99

10-
if path.is_empty():
11-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
12-
13-
if not path.begins_with("res://"):
14-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
10+
var path_err := McpPathValidator.validate_resource_path(path)
11+
if not path_err.is_empty():
12+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, path_err)
1513

1614
if not FileAccess.file_exists(path):
1715
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "File not found: %s" % path)
@@ -37,11 +35,9 @@ func write_file(params: Dictionary) -> Dictionary:
3735
var path: String = params.get("path", "")
3836
var content: String = params.get("content", "")
3937

40-
if path.is_empty():
41-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
42-
43-
if not path.begins_with("res://"):
44-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
38+
var path_err := McpPathValidator.validate_resource_path(path)
39+
if not path_err.is_empty():
40+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, path_err)
4541

4642
# Ensure parent directory exists
4743
var dir_path := path.get_base_dir()
@@ -87,8 +83,9 @@ func reimport(params: Dictionary) -> Dictionary:
8783

8884
for path_variant in paths:
8985
var path: String = str(path_variant)
90-
if not path.begins_with("res://"):
91-
not_found.append("%s (must start with res://)" % path)
86+
var path_err := McpPathValidator.validate_resource_path(path)
87+
if not path_err.is_empty():
88+
not_found.append("%s (%s)" % [path, path_err])
9289
continue
9390
if not FileAccess.file_exists(path):
9491
not_found.append("%s (file does not exist)" % path)

plugin/addons/godot_ai/handlers/script_handler.gd

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@ func create_script(params: Dictionary) -> Dictionary:
2525
var path: String = params.get("path", "")
2626
var content: String = params.get("content", "")
2727

28-
if path.is_empty():
29-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
30-
31-
if not path.begins_with("res://"):
32-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
28+
var path_err := McpPathValidator.validate_resource_path(path)
29+
if not path_err.is_empty():
30+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, path_err)
3331

3432
if not path.ends_with(".gd"):
3533
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must end with .gd")
@@ -116,11 +114,9 @@ func _finish_create_script_deferred(request_id: String, path: String, data: Dict
116114
func read_script(params: Dictionary) -> Dictionary:
117115
var path: String = params.get("path", "")
118116

119-
if path.is_empty():
120-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
121-
122-
if not path.begins_with("res://"):
123-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
117+
var path_err := McpPathValidator.validate_resource_path(path)
118+
if not path_err.is_empty():
119+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, path_err)
124120

125121
if not FileAccess.file_exists(path):
126122
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "File not found: %s" % path)
@@ -148,14 +144,13 @@ func patch_script(params: Dictionary) -> Dictionary:
148144
var new_text: String = params.get("new_text", "")
149145
var replace_all: bool = params.get("replace_all", false)
150146

151-
if path.is_empty():
152-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
147+
var path_err := McpPathValidator.validate_resource_path(path)
148+
if not path_err.is_empty():
149+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, path_err)
153150
if not "old_text" in params:
154151
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: old_text")
155152
if not "new_text" in params:
156153
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: new_text")
157-
if not path.begins_with("res://"):
158-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
159154
if not path.ends_with(".gd"):
160155
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must end with .gd (use filesystem_write_text for other text files)")
161156
if old_text.is_empty():
@@ -283,11 +278,9 @@ func detach_script(params: Dictionary) -> Dictionary:
283278
func find_symbols(params: Dictionary) -> Dictionary:
284279
var path: String = params.get("path", "")
285280

286-
if path.is_empty():
287-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: path")
288-
289-
if not path.begins_with("res://"):
290-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res://")
281+
var path_err := McpPathValidator.validate_resource_path(path)
282+
if not path_err.is_empty():
283+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, path_err)
291284

292285
if not FileAccess.file_exists(path):
293286
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "File not found: %s" % path)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
@tool
2+
class_name McpPathValidator
3+
extends RefCounted
4+
5+
## Validates `res://`-rooted paths against directory-traversal escape.
6+
##
7+
## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd`
8+
## because the only check was `path.begins_with("res://")`. LLM-driven path
9+
## generation (prompt injection, agent typos, untrusted issue/PR text in
10+
## context) can produce traversal payloads for the write tools that produce
11+
## arbitrary disk content (`script_create`, `filesystem_write_text`,
12+
## `patch_script`) and for the matching reads (info disclosure surface).
13+
##
14+
## Layered checks:
15+
## 1. non-empty
16+
## 2. begins with `res://`
17+
## 3. no `..` substring (cheap, catches every common traversal payload)
18+
## 4. globalize → simplify → verify still under the project root
19+
## (defence-in-depth against URL-encoded or otherwise sneaky shapes
20+
## that simplify_path collapses but the substring check might miss)
21+
22+
23+
# Cached project root. `ProjectSettings.globalize_path("res://")` is stable
24+
# across the editor's lifetime — caching avoids redundant resolution on every
25+
# call. Matters most for `reimport`, which loops the validator over each path
26+
# in a batch. Lazy-init on first call so static-load timing can't see a
27+
# half-initialised ProjectSettings.
28+
static var _cached_res_root: String = ""
29+
30+
31+
static func _res_root() -> String:
32+
if _cached_res_root.is_empty():
33+
_cached_res_root = ProjectSettings.globalize_path("res://").simplify_path()
34+
return _cached_res_root
35+
36+
37+
## Returns "" when the path is a safe `res://`-rooted reference inside the
38+
## project root. Returns a human-readable error message otherwise; callers
39+
## wrap it with `McpErrorCodes.make(INVALID_PARAMS, ...)`.
40+
static func validate_resource_path(path: String) -> String:
41+
if path.is_empty():
42+
return "Missing required param: path"
43+
if not path.begins_with("res://"):
44+
return "Path must start with res://"
45+
if ".." in path:
46+
return "Path must not contain '..' (path traversal not allowed)"
47+
var globalized := ProjectSettings.globalize_path(path).simplify_path()
48+
var res_root := _res_root()
49+
# Append a separator so `/proj_evil/...` can't pretend to be inside
50+
# `/proj` via prefix match. `globalized == res_root` covers `path == "res://"`.
51+
if globalized != res_root and not globalized.begins_with(res_root + "/"):
52+
return "Path must resolve under res:// root"
53+
return ""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://blxntmd65ljyu

test_project/tests/test_filesystem.gd

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ func test_read_file_not_found() -> void:
5959
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
6060

6161

62+
func test_read_file_rejects_traversal_path() -> void:
63+
## Issue #347: traversal in read_file is the file-disclosure primitive.
64+
var result := _handler.read_file({"path": "res://../etc/passwd"})
65+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
66+
assert_contains(result.error.message, "..")
67+
68+
6269
# ----- write_file -----
6370

6471
func test_write_file_basic() -> void:
@@ -105,6 +112,23 @@ func test_write_file_invalid_prefix() -> void:
105112
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
106113

107114

115+
func test_write_file_rejects_traversal_path() -> void:
116+
## Issue #347: the actual arbitrary-disk-write primitive.
117+
## Use a synthetic target so a Unix host's pre-existing /etc/* doesn't
118+
## false-positive the disk-state assertion below. If a regression let
119+
## the write through, the file would land one dir above the project at
120+
## `<project_parent>/__mcp_traversal_test_target__`, which never
121+
## exists in a clean tree.
122+
var traversal_path := "res://../__mcp_traversal_test_target__.txt"
123+
var result := _handler.write_file({
124+
"path": traversal_path,
125+
"content": "owned\n",
126+
})
127+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
128+
assert_contains(result.error.message, "..")
129+
assert_false(FileAccess.file_exists(traversal_path), "traversal must not write to disk")
130+
131+
108132
# ----- reimport -----
109133

110134
func test_reimport_missing_paths() -> void:
@@ -139,3 +163,12 @@ func test_reimport_invalid_prefix() -> void:
139163
assert_has_key(result, "data")
140164
assert_eq(result.data.reimported_count, 0)
141165
assert_eq(result.data.not_found_count, 1)
166+
167+
168+
func test_reimport_rejects_traversal_path() -> void:
169+
## Issue #347: per-path validation in the loop must catch traversal too.
170+
var result := _handler.reimport({"paths": ["res://../etc/passwd"]})
171+
assert_has_key(result, "data")
172+
assert_eq(result.data.reimported_count, 0)
173+
assert_eq(result.data.not_found_count, 1)
174+
assert_contains(result.data.not_found[0], "..")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
@tool
2+
extends McpTestSuite
3+
4+
## Tests for McpPathValidator — the resource-path traversal guard shared by
5+
## script_handler and filesystem_handler. Issue #347 (audit-v2 #3): paths
6+
## like `res://../etc/passwd.gd` were passing the bare prefix check.
7+
8+
9+
func suite_name() -> String:
10+
return "path_validator"
11+
12+
13+
# ----- happy path -----
14+
15+
func test_valid_simple_path_returns_empty() -> void:
16+
assert_eq(McpPathValidator.validate_resource_path("res://main.tscn"), "")
17+
18+
19+
func test_valid_nested_path_returns_empty() -> void:
20+
assert_eq(McpPathValidator.validate_resource_path("res://addons/godot_ai/plugin.gd"), "")
21+
22+
23+
func test_valid_root_path_returns_empty() -> void:
24+
## "res://" itself has no traversal and resolves exactly to the project
25+
## root, so the validator must not reject it on the boundary check.
26+
assert_eq(McpPathValidator.validate_resource_path("res://"), "")
27+
28+
29+
# ----- empty + prefix -----
30+
31+
func test_empty_path_rejected() -> void:
32+
var err := McpPathValidator.validate_resource_path("")
33+
assert_false(err.is_empty(), "empty path must report an error")
34+
assert_contains(err, "Missing required param")
35+
36+
37+
func test_missing_prefix_rejected() -> void:
38+
var err := McpPathValidator.validate_resource_path("/tmp/foo.gd")
39+
assert_false(err.is_empty(), "absolute path without res:// must be rejected")
40+
assert_contains(err, "res://")
41+
42+
43+
func test_user_prefix_rejected() -> void:
44+
## user:// is a valid Godot scheme but it's outside the project — agents
45+
## must not be able to write to user:// via the same handlers (they have
46+
## different lifecycle and permission semantics).
47+
var err := McpPathValidator.validate_resource_path("user://save.dat")
48+
assert_false(err.is_empty(), "user:// path must be rejected")
49+
assert_contains(err, "res://")
50+
51+
52+
# ----- traversal regressions (the actual security guard) -----
53+
54+
func test_rejects_dotdot_at_root() -> void:
55+
## The exact attack shape called out in issue #347.
56+
var err := McpPathValidator.validate_resource_path("res://../etc/passwd.gd")
57+
assert_false(err.is_empty(), "res://../etc/passwd.gd must be rejected")
58+
assert_contains(err, "..")
59+
60+
61+
func test_rejects_dotdot_nested() -> void:
62+
var err := McpPathValidator.validate_resource_path("res://addons/../../etc/passwd")
63+
assert_false(err.is_empty(), "nested traversal must be rejected")
64+
assert_contains(err, "..")
65+
66+
67+
func test_rejects_deep_dotdot_chain() -> void:
68+
## Defence in depth: even if a payload chains through legitimate-looking
69+
## subdirectories first, the substring check fires.
70+
var err := McpPathValidator.validate_resource_path("res://addons/godot_ai/../../../etc/passwd.gd")
71+
assert_false(err.is_empty(), "deep traversal chain must be rejected")
72+
73+
74+
func test_rejects_dotdot_in_filename() -> void:
75+
## Per the audit's fix shape: reject any path containing `..`. A filename
76+
## like `my..backup.json` is unusual enough that we accept the false-
77+
## positive cost in exchange for a simpler, shorter security boundary.
78+
var err := McpPathValidator.validate_resource_path("res://data/my..backup.json")
79+
assert_false(err.is_empty(), "literal '..' anywhere in path must be rejected")
80+
81+
82+
# ----- boundary check (defence in depth past the substring guard) -----
83+
84+
func test_well_formed_nested_path_passes_boundary_check() -> void:
85+
## Sanity: a path with no `..` substring still has to clear the
86+
## globalize_path → simplify_path → boundary check. This pins the safe
87+
## path so a regression in the boundary comparison (e.g. trailing-slash
88+
## handling) couldn't silently reject legitimate paths.
89+
##
90+
## Direct traversal payloads can't reach the boundary check — they're
91+
## caught by the `..` substring rejection above — so there's no
92+
## non-`..` traversal payload to assert rejection on. The boundary
93+
## check exists as defence-in-depth for any future encoding-bypass
94+
## that smuggles a `..` past the substring guard.
95+
var safe := McpPathValidator.validate_resource_path("res://addons/godot_ai")
96+
assert_eq(safe, "", "well-formed nested path must validate")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://03e3vouyp30v

test_project/tests/test_script.gd

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ func test_create_script_wrong_extension() -> void:
106106
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
107107

108108

109+
func test_create_script_rejects_traversal_path() -> void:
110+
## Issue #347: `res://../etc/passwd.gd` previously passed the prefix check.
111+
## Use a synthetic target so a host with a pre-existing
112+
## `<project_parent>/etc/passwd.gd` couldn't false-positive the disk
113+
## assertion. The synthetic name never exists in a clean tree.
114+
var traversal_path := "res://../__mcp_traversal_test_target__.gd"
115+
var result := _handler.create_script({
116+
"path": traversal_path,
117+
"content": "extends Node\n",
118+
})
119+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
120+
assert_contains(result.error.message, "..")
121+
## Defence: confirm the file was NOT written outside the project.
122+
assert_false(FileAccess.file_exists(traversal_path), "traversal must not write to disk")
123+
124+
109125
# ----- patch_script -----
110126

111127
func test_patch_script_basic() -> void:
@@ -222,6 +238,18 @@ func test_patch_script_invalid_prefix() -> void:
222238
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
223239

224240

241+
func test_patch_script_rejects_traversal_path() -> void:
242+
## Issue #347 regression: traversal must be caught before the file is
243+
## opened for read or write.
244+
var result := _handler.patch_script({
245+
"path": "res://../etc/passwd.gd",
246+
"old_text": "x",
247+
"new_text": "y",
248+
})
249+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
250+
assert_contains(result.error.message, "..")
251+
252+
225253
# ----- read_script -----
226254

227255
func test_read_script_basic() -> void:
@@ -248,6 +276,13 @@ func test_read_script_not_found() -> void:
248276
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
249277

250278

279+
func test_read_script_rejects_traversal_path() -> void:
280+
## Issue #347: read_script must not become a file-disclosure primitive.
281+
var result := _handler.read_script({"path": "res://../etc/passwd.gd"})
282+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
283+
assert_contains(result.error.message, "..")
284+
285+
251286
# ----- attach_script -----
252287

253288
func test_attach_script_basic() -> void:
@@ -378,3 +413,10 @@ func test_find_symbols_invalid_prefix() -> void:
378413
func test_find_symbols_not_found() -> void:
379414
var result := _handler.find_symbols({"path": "res://nonexistent_script.gd"})
380415
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
416+
417+
418+
func test_find_symbols_rejects_traversal_path() -> void:
419+
## Issue #347: find_symbols also reads file content; same disclosure surface.
420+
var result := _handler.find_symbols({"path": "res://../etc/passwd.gd"})
421+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
422+
assert_contains(result.error.message, "..")

0 commit comments

Comments
 (0)