|
| 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") |
0 commit comments