diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py index d41bd30ed..c28462c73 100644 --- a/src/apm_cli/models/dependency/reference.py +++ b/src/apm_cli/models/dependency/reference.py @@ -655,6 +655,15 @@ def _parse_ssh_url(dependency_str: str): repo_part = repo_part[:-4] repo_url = repo_part.strip() + + # Reject path traversal sequences in SSH URLs + for segment in repo_url.split('/'): + if not segment or segment in ('.', '..'): + raise PathTraversalError( + f"Invalid SSH repository path '{repo_url}': " + f"path segments must not be empty or be '.' or '..'" + ) + return host, repo_url, reference, alias @classmethod diff --git a/tests/unit/test_path_security.py b/tests/unit/test_path_security.py index f93f95038..76eb6d5ca 100644 --- a/tests/unit/test_path_security.py +++ b/tests/unit/test_path_security.py @@ -177,6 +177,47 @@ def test_parse_accepts_normal_virtual_package(self): dep = DependencyReference.parse("owner/repo/prompts/my-file.prompt.md") assert dep.is_virtual is True + # --- SSH URL traversal rejection --- + + def test_ssh_parse_rejects_dotdot_in_repo(self): + """SSH URLs with '..' traversal in the repo path must be rejected.""" + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:owner/../evil") + + def test_ssh_parse_rejects_nested_dotdot(self): + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:org/../../etc/passwd") + + def test_ssh_parse_rejects_single_dot(self): + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:owner/./repo") + + def test_ssh_parse_accepts_normal_url(self): + dep = DependencyReference.parse("git@github.com:owner/repo#main") + assert dep.repo_url == "owner/repo" + assert dep.reference == "main" + + def test_ssh_parse_accepts_url_with_git_suffix(self): + dep = DependencyReference.parse("git@gitlab.com:team/project.git#v1.0") + assert dep.repo_url == "team/project" + assert dep.reference == "v1.0" + + def test_ssh_parse_rejects_dotdot_with_alias(self): + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:owner/../evil@my-alias") + + def test_ssh_parse_rejects_dotdot_with_reference(self): + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:owner/../../etc#main") + + def test_ssh_parse_rejects_double_slash(self): + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:owner//repo") + + def test_ssh_parse_rejects_trailing_slash(self): + with pytest.raises(PathTraversalError): + DependencyReference.parse("git@github.com:owner/repo/") + # --------------------------------------------------------------------------- # DependencyReference.get_install_path containment