Skip to content

Commit 8549142

Browse files
refactor: consolidate path traversal validation into shared utility
Replace inline segment-traversal checks in DependencyReference with calls to a single validate_path_segments() function in path_security.py. Replaces the inline SSH check from #458 with the shared utility. The new utility normalises backslashes, rejects '.' and '..' segments, and optionally rejects empty segments -- applied uniformly across all parse paths (SSH, HTTPS, shorthand, dict, virtual) and the defense-in-depth layer in get_install_path(). - Add validate_path_segments() to utils/path_security.py - Replace all inline checks in models/dependency/reference.py - Add 28 new tests (18 utility + 10 SSH traversal) - 3107 tests pass, zero regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 757b5ad commit 8549142

File tree

4 files changed

+160
-57
lines changed

4 files changed

+160
-57
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Skills now deploy to all active targets (`.opencode/`, `.cursor/`) instead of only `.github/` (#456)
1616
- `apm install` no longer rewrites `apm.lock.yaml` when dependencies are unchanged, eliminating `generated_at` churn in version control (#456)
1717
- `.github/` is no longer auto-created when other target dirs (`.claude/`, `.cursor/`, `.opencode/`) already exist; copilot is only the fallback for greenfield projects (#456)
18+
- SSH-style Git URLs (`git@host:owner/../evil`) now reject path traversal sequences, closing a bypass of the HTTPS validation added in #437 -- by @thakoreh (#458)
19+
20+
### Changed
21+
22+
- Consolidated path-segment traversal checks in `DependencyReference` into a single `validate_path_segments()` utility in `path_security.py`, eliminating behavioral drift (backslash normalisation now applied uniformly across all parse paths)
1823

1924
### Added
2025

src/apm_cli/models/dependency/reference.py

Lines changed: 32 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
parse_artifactory_path,
1616
unsupported_host_error,
1717
)
18-
from ...utils.path_security import PathTraversalError, ensure_path_within
18+
from ...utils.path_security import (
19+
PathTraversalError,
20+
ensure_path_within,
21+
validate_path_segments,
22+
)
1923
from ..validation import InvalidVirtualPackageExtensionError
2024
from .types import VirtualPackageType
2125

@@ -309,33 +313,23 @@ def get_install_path(self, apm_modules_dir: Path) -> Path:
309313
"""
310314
if self.is_local and self.local_path:
311315
pkg_dir_name = Path(self.local_path).name
312-
if pkg_dir_name in ("", ".", ".."):
313-
raise PathTraversalError(
314-
f"Invalid local package path '{self.local_path}': "
315-
f"basename must not be empty, '.', or '..'"
316-
)
316+
validate_path_segments(
317+
pkg_dir_name,
318+
context="local package path",
319+
reject_empty=True,
320+
)
317321
result = apm_modules_dir / "_local" / pkg_dir_name
318322
ensure_path_within(result, apm_modules_dir)
319323
return result
320324

321325
repo_parts = self.repo_url.split("/")
322326

323327
# Security: reject traversal in repo_url segments (catches lockfile injection)
324-
if any(seg in (".", "..") for seg in repo_parts):
325-
raise PathTraversalError(
326-
f"Invalid repo_url '{self.repo_url}': "
327-
f"path segments must not be '.' or '..'"
328-
)
328+
validate_path_segments(self.repo_url, context="repo_url")
329329

330330
# Security: reject traversal in virtual_path (catches lockfile injection)
331-
if self.virtual_path and any(
332-
seg in (".", "..")
333-
for seg in self.virtual_path.replace("\\", "/").split("/")
334-
):
335-
raise PathTraversalError(
336-
f"Invalid virtual_path '{self.virtual_path}': "
337-
f"path segments must not be '.' or '..'"
338-
)
331+
if self.virtual_path:
332+
validate_path_segments(self.virtual_path, context="virtual_path")
339333
result: Path | None = None
340334

341335
if self.is_virtual:
@@ -485,10 +479,7 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
485479
# Normalize backslashes to forward slashes for cross-platform safety
486480
sub_path = sub_path.replace("\\", "/").strip().strip("/")
487481
# Security: reject path traversal
488-
if any(seg in (".", "..") for seg in sub_path.split("/")):
489-
raise PathTraversalError(
490-
f"Invalid path '{sub_path}': path segments must not be '.' or '..'"
491-
)
482+
validate_path_segments(sub_path, context="path")
492483

493484
# Parse the git URL using the standard parser
494485
dep = cls.parse(git_url)
@@ -603,11 +594,7 @@ def _detect_virtual_package(cls, dependency_str: str):
603594
virtual_path = "/".join(path_segments[min_base_segments:])
604595

605596
# Security: reject path traversal in virtual path
606-
vp_check = virtual_path.replace("\\", "/")
607-
if any(seg in (".", "..") for seg in vp_check.split("/")):
608-
raise PathTraversalError(
609-
f"Invalid virtual path '{virtual_path}': path segments must not be '.' or '..'"
610-
)
597+
validate_path_segments(virtual_path, context="virtual path")
611598

612599
if "/collections/" in check_str or virtual_path.startswith("collections/"):
613600
pass
@@ -656,13 +643,10 @@ def _parse_ssh_url(dependency_str: str):
656643

657644
repo_url = repo_part.strip()
658645

659-
# Reject path traversal sequences in SSH URLs
660-
for segment in repo_url.split('/'):
661-
if not segment or segment in ('.', '..'):
662-
raise PathTraversalError(
663-
f"Invalid SSH repository path '{repo_url}': "
664-
f"path segments must not be empty or be '.' or '..'"
665-
)
646+
# Security: reject traversal sequences in SSH repo paths
647+
validate_path_segments(
648+
repo_url, context="SSH repository path", reject_empty=True
649+
)
666650

667651
return host, repo_url, reference, alias
668652

@@ -790,11 +774,10 @@ def _parse_standard_url(
790774
allowed_pattern = (
791775
r"^[a-zA-Z0-9._\- ]+$" if is_ado_host else r"^[a-zA-Z0-9._-]+$"
792776
)
777+
validate_path_segments(
778+
"/".join(uparts), context="repository path"
779+
)
793780
for part in uparts:
794-
if part in (".", ".."):
795-
raise PathTraversalError(
796-
f"Invalid repository path component: '{part}' is a traversal sequence"
797-
)
798781
if not re.match(allowed_pattern, part.rstrip(".git")):
799782
raise ValueError(f"Invalid repository path component: {part}")
800783

@@ -840,11 +823,12 @@ def _parse_standard_url(
840823
allowed_pattern = (
841824
r"^[a-zA-Z0-9._\- ]+$" if is_ado_host else r"^[a-zA-Z0-9._-]+$"
842825
)
843-
for i, part in enumerate(path_parts):
844-
if not part:
845-
raise ValueError(
846-
f"Invalid repository format: path component {i+1} cannot be empty"
847-
)
826+
validate_path_segments(
827+
"/".join(path_parts),
828+
context="repository URL path",
829+
reject_empty=True,
830+
)
831+
for part in path_parts:
848832
if not re.match(allowed_pattern, part):
849833
raise ValueError(f"Invalid repository path component: {part}")
850834

@@ -946,12 +930,9 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
946930
f"Invalid Azure DevOps repository format: {repo_url}. Expected 'org/project/repo'"
947931
)
948932
ado_parts = repo_url.split("/")
949-
for part in ado_parts:
950-
if part in (".", ".."):
951-
raise PathTraversalError(
952-
f"Path traversal segment '{part}' is not allowed in "
953-
f"Azure DevOps repository path: {repo_url}"
954-
)
933+
validate_path_segments(
934+
repo_url, context="Azure DevOps repository path"
935+
)
955936
ado_organization = ado_parts[0]
956937
ado_project = ado_parts[1]
957938
ado_repo = ado_parts[2]
@@ -965,12 +946,8 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
965946
raise ValueError(
966947
f"Invalid repository format: {repo_url}. Contains invalid characters"
967948
)
949+
validate_path_segments(repo_url, context="repository path")
968950
for seg in segments:
969-
if seg in (".", ".."):
970-
raise ValueError(
971-
f"Invalid repository format: {repo_url}. "
972-
f"Contains '.' or '..' path segments"
973-
)
974951
if any(seg.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
975952
raise ValueError(
976953
f"Invalid repository format: '{repo_url}' contains a virtual file extension. "

src/apm_cli/utils/path_security.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
77
Design
88
------
9-
* ``ensure_path_within`` is the single predicate -- resolves both paths and
10-
asserts containment via ``Path.is_relative_to``.
9+
* ``validate_path_segments`` rejects traversal sequences (``.`` / ``..``)
10+
at parse time -- before any path is constructed or written.
11+
* ``ensure_path_within`` is the single predicate for filesystem
12+
containment -- resolves both paths and asserts via
13+
``Path.is_relative_to``.
1114
* ``safe_rmtree`` wraps ``robust_rmtree`` with an ``ensure_path_within``
1215
check so callers get a drop-in replacement.
1316
* ``PathTraversalError`` is a ``ValueError`` subclass for clear error
@@ -25,6 +28,45 @@ class PathTraversalError(ValueError):
2528
"""Raised when a computed path escapes its expected base directory."""
2629

2730

31+
def validate_path_segments(
32+
path_str: str,
33+
*,
34+
context: str = "path",
35+
reject_empty: bool = False,
36+
) -> None:
37+
"""Reject path strings containing traversal sequences.
38+
39+
Normalises backslashes to forward slashes, splits on ``/``, and
40+
rejects any segment that is ``.`` or ``..``. Optionally rejects
41+
empty segments (from ``//`` or trailing ``/``).
42+
43+
Parameters
44+
----------
45+
path_str : str
46+
Path-like string to validate (repo URL, virtual path, etc.).
47+
context : str
48+
Human-readable label for error messages.
49+
reject_empty : bool
50+
If *True*, also reject empty segments.
51+
52+
Raises
53+
------
54+
PathTraversalError
55+
If any segment fails validation.
56+
"""
57+
for segment in path_str.replace("\\", "/").split("/"):
58+
if segment in (".", ".."):
59+
raise PathTraversalError(
60+
f"Invalid {context} '{path_str}': "
61+
f"segment '{segment}' is a traversal sequence"
62+
)
63+
if reject_empty and not segment:
64+
raise PathTraversalError(
65+
f"Invalid {context} '{path_str}': "
66+
f"path segments must not be empty"
67+
)
68+
69+
2870
def ensure_path_within(path: Path, base_dir: Path) -> Path:
2971
"""Resolve *path* and assert it lives inside *base_dir*.
3072

tests/unit/test_path_security.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
PathTraversalError,
1616
ensure_path_within,
1717
safe_rmtree,
18+
validate_path_segments,
1819
)
1920
from apm_cli.models.dependency import DependencyReference
2021

@@ -118,6 +119,84 @@ def test_refuses_traversal_in_package_name(self, tmp_path):
118119
assert victim.exists()
119120

120121

122+
# ---------------------------------------------------------------------------
123+
# validate_path_segments
124+
# ---------------------------------------------------------------------------
125+
126+
127+
class TestValidatePathSegments:
128+
"""Unit tests for the validate_path_segments utility."""
129+
130+
def test_accepts_clean_path(self):
131+
validate_path_segments("owner/repo")
132+
133+
def test_accepts_single_segment(self):
134+
validate_path_segments("repo")
135+
136+
def test_accepts_deep_path(self):
137+
validate_path_segments("org/project/repo/sub/dir")
138+
139+
def test_rejects_dotdot(self):
140+
with pytest.raises(PathTraversalError):
141+
validate_path_segments("owner/../evil")
142+
143+
def test_rejects_single_dot(self):
144+
with pytest.raises(PathTraversalError):
145+
validate_path_segments("owner/./repo")
146+
147+
def test_rejects_leading_dotdot(self):
148+
with pytest.raises(PathTraversalError):
149+
validate_path_segments("../escape")
150+
151+
def test_rejects_nested_dotdot(self):
152+
with pytest.raises(PathTraversalError):
153+
validate_path_segments("a/b/../../c")
154+
155+
def test_rejects_backslash_dotdot(self):
156+
"""Backslashes are normalised to forward slashes before checking."""
157+
with pytest.raises(PathTraversalError):
158+
validate_path_segments("owner\\..\\evil")
159+
160+
def test_rejects_mixed_separators(self):
161+
with pytest.raises(PathTraversalError):
162+
validate_path_segments("sub\\..\\..\\esc")
163+
164+
def test_empty_segments_allowed_by_default(self):
165+
# Double-slash produces empty segments; allowed unless reject_empty
166+
validate_path_segments("owner//repo")
167+
168+
def test_reject_empty_catches_double_slash(self):
169+
with pytest.raises(PathTraversalError):
170+
validate_path_segments("owner//repo", reject_empty=True)
171+
172+
def test_reject_empty_catches_trailing_slash(self):
173+
with pytest.raises(PathTraversalError):
174+
validate_path_segments("owner/repo/", reject_empty=True)
175+
176+
def test_reject_empty_catches_leading_slash(self):
177+
with pytest.raises(PathTraversalError):
178+
validate_path_segments("/owner/repo", reject_empty=True)
179+
180+
def test_reject_empty_passes_clean_path(self):
181+
validate_path_segments("owner/repo", reject_empty=True)
182+
183+
def test_context_appears_in_message(self):
184+
with pytest.raises(PathTraversalError, match="repo_url"):
185+
validate_path_segments("a/../b", context="repo_url")
186+
187+
def test_bare_dot_rejected(self):
188+
with pytest.raises(PathTraversalError):
189+
validate_path_segments(".")
190+
191+
def test_bare_dotdot_rejected(self):
192+
with pytest.raises(PathTraversalError):
193+
validate_path_segments("..")
194+
195+
def test_empty_string_with_reject_empty(self):
196+
with pytest.raises(PathTraversalError):
197+
validate_path_segments("", reject_empty=True)
198+
199+
121200
# ---------------------------------------------------------------------------
122201
# DependencyReference parse-time traversal rejection
123202
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)