Skip to content

Commit 2dc8ee5

Browse files
committed
fix(security): use commonpath for cross-platform path containment check
Address Copilot review feedback: - Replace startswith() with os.path.commonpath() + normcase() for robust containment on case-insensitive filesystems (Windows/macOS) - Reject paths resolving to base_dir itself (dest_local='.') - Add symlink escape test and base-dir-resolve test - Fix existing tests to use real temp directories instead of mocking os.path.join Signed-off-by: Yash Goel <yashhzd@users.noreply.github.com>
1 parent 922ce45 commit 2dc8ee5

File tree

2 files changed

+76
-26
lines changed

2 files changed

+76
-26
lines changed

ardupilot_methodic_configurator/backend_filesystem.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -985,18 +985,27 @@ def _safe_path_join(base_dir: str, untrusted_path: str) -> str:
985985
"""
986986
Safely join a base directory with an untrusted path component.
987987
988-
Validates that the resolved path stays within base_dir to prevent
988+
Validates that the resolved path is a proper child of base_dir to prevent
989989
path traversal attacks via values like '../../.bashrc' from JSON configs.
990+
Uses os.path.commonpath for cross-platform robustness (handles case-insensitive
991+
filesystems on Windows/macOS).
990992
991993
Raises:
992-
ValueError: If the resolved path escapes base_dir.
994+
ValueError: If the resolved path escapes base_dir or resolves to base_dir itself.
993995
994996
"""
995-
resolved = os_path.realpath(os_path.join(base_dir, untrusted_path))
996-
if not resolved.startswith(os_path.realpath(base_dir) + os_path.sep) and resolved != os_path.realpath(base_dir):
997+
base_real = os_path.normcase(os_path.realpath(base_dir))
998+
resolved = os_path.normcase(os_path.realpath(os_path.join(base_dir, untrusted_path)))
999+
try:
1000+
common = os_path.commonpath([base_real, resolved])
1001+
except ValueError:
1002+
# On Windows, commonpath raises ValueError for paths on different drives
1003+
msg = f"Path escapes vehicle directory: {untrusted_path!r}"
1004+
raise ValueError(msg) from None
1005+
if common != base_real or resolved == base_real:
9971006
msg = f"Path escapes vehicle directory: {untrusted_path!r}"
9981007
raise ValueError(msg)
999-
return resolved
1008+
return os_path.realpath(os_path.join(base_dir, untrusted_path))
10001009

10011010
def get_download_url_and_local_filename(self, selected_file: str) -> tuple[str, str]:
10021011
if selected_file in self.configuration_steps and self.configuration_steps[selected_file].get("download_file"):

tests/test_backend_filesystem.py

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -952,36 +952,34 @@ def test_zip_files(self) -> None:
952952

953953
def test_get_download_url_and_local_filename_with_valid_config(self) -> None:
954954
"""Test get_download_url_and_local_filename with valid configuration."""
955-
lfs = LocalFilesystem(
956-
"vehicle_dir", "vehicle_type", None, allow_editing_template_files=False, save_component_to_system_templates=False
957-
)
958-
959-
# Set up configuration steps with download file section
960-
lfs.configuration_steps = {
961-
"test_file.param": {
962-
"download_file": {"source_url": "https://example.com/file.bin", "dest_local": "local_file.bin"}
955+
with tempfile.TemporaryDirectory() as tmp_dir:
956+
lfs = LocalFilesystem(
957+
tmp_dir, "vehicle_type", None, allow_editing_template_files=False, save_component_to_system_templates=False
958+
)
959+
lfs.configuration_steps = {
960+
"test_file.param": {
961+
"download_file": {"source_url": "https://example.com/file.bin", "dest_local": "local_file.bin"}
962+
}
963963
}
964-
}
965964

966-
with patch("os.path.join", return_value="vehicle_dir/local_file.bin"):
967965
url, local_path = lfs.get_download_url_and_local_filename("test_file.param")
968966
assert url == "https://example.com/file.bin"
969-
assert local_path == "vehicle_dir/local_file.bin"
967+
assert local_path == os_path.realpath(os_path.join(tmp_dir, "local_file.bin"))
970968

971969
def test_get_upload_local_and_remote_filenames_with_valid_config(self) -> None:
972970
"""Test get_upload_local_and_remote_filenames with valid configuration."""
973-
lfs = LocalFilesystem(
974-
"vehicle_dir", "vehicle_type", None, allow_editing_template_files=False, save_component_to_system_templates=False
975-
)
976-
977-
# Set up configuration steps with upload file section
978-
lfs.configuration_steps = {
979-
"test_file.param": {"upload_file": {"source_local": "local_file.bin", "dest_on_fc": "/fs/microsd/APM/file.bin"}}
980-
}
971+
with tempfile.TemporaryDirectory() as tmp_dir:
972+
lfs = LocalFilesystem(
973+
tmp_dir, "vehicle_type", None, allow_editing_template_files=False, save_component_to_system_templates=False
974+
)
975+
lfs.configuration_steps = {
976+
"test_file.param": {
977+
"upload_file": {"source_local": "local_file.bin", "dest_on_fc": "/fs/microsd/APM/file.bin"}
978+
}
979+
}
981980

982-
with patch("os.path.join", return_value="vehicle_dir/local_file.bin"):
983981
local_path, remote_path = lfs.get_upload_local_and_remote_filenames("test_file.param")
984-
assert local_path == "vehicle_dir/local_file.bin"
982+
assert local_path == os_path.realpath(os_path.join(tmp_dir, "local_file.bin"))
985983
assert remote_path == "/fs/microsd/APM/file.bin"
986984

987985

@@ -1061,6 +1059,49 @@ def test_download_rejects_absolute_path(self, tmp_path) -> None:
10611059
with pytest.raises(ValueError, match="Path escapes vehicle directory"):
10621060
lfs.get_download_url_and_local_filename("test.param")
10631061

1062+
def test_download_rejects_dest_local_resolving_to_base_dir(self, tmp_path) -> None:
1063+
"""
1064+
dest_local='.' resolves to vehicle_dir itself and is rejected.
1065+
1066+
GIVEN: A configuration step with dest_local='.' (resolves to base directory)
1067+
WHEN: get_download_url_and_local_filename is called
1068+
THEN: A ValueError is raised because a directory path is not a valid file target
1069+
"""
1070+
lfs = LocalFilesystem(
1071+
str(tmp_path), "vehicle_type", None, allow_editing_template_files=False, save_component_to_system_templates=False
1072+
)
1073+
lfs.configuration_steps = {
1074+
"test.param": {"download_file": {"source_url": "https://example.com/payload", "dest_local": "."}}
1075+
}
1076+
1077+
with pytest.raises(ValueError, match="Path escapes vehicle directory"):
1078+
lfs.get_download_url_and_local_filename("test.param")
1079+
1080+
def test_download_rejects_symlink_escape(self, tmp_path) -> None:
1081+
"""
1082+
Symlink pointing outside vehicle_dir is blocked.
1083+
1084+
GIVEN: A symlink inside vehicle_dir that points to an external directory
1085+
WHEN: dest_local references a file through the symlink
1086+
THEN: A ValueError is raised because the resolved path escapes vehicle_dir
1087+
"""
1088+
# Create a symlink inside tmp_path pointing to /tmp
1089+
symlink_path = tmp_path / "escape_link"
1090+
try:
1091+
symlink_path.symlink_to("/tmp")
1092+
except OSError:
1093+
pytest.skip("Cannot create symlinks in this environment")
1094+
1095+
lfs = LocalFilesystem(
1096+
str(tmp_path), "vehicle_type", None, allow_editing_template_files=False, save_component_to_system_templates=False
1097+
)
1098+
lfs.configuration_steps = {
1099+
"test.param": {"download_file": {"source_url": "https://example.com/payload", "dest_local": "escape_link/evil"}}
1100+
}
1101+
1102+
with pytest.raises(ValueError, match="Path escapes vehicle directory"):
1103+
lfs.get_download_url_and_local_filename("test.param")
1104+
10641105

10651106
class TestTransformParamDict:
10661107
"""Unit tests for LocalFilesystem._transform_param_dict (pure in-memory, no filesystem)."""

0 commit comments

Comments
 (0)