From 4d91554fe6fcab7ef4925da1b36602125101ed45 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sat, 6 Jun 2026 17:08:34 -0700 Subject: [PATCH] fix(config): reject path traversal in policy_bundle_path and catalog_path (CONF-004) Closes #182. Co-Authored-By: Claude Sonnet 4.6 --- src/cmcp_gateway/config.py | 24 ++++++++++++++++++++++-- tests/unit/test_config.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/cmcp_gateway/config.py b/src/cmcp_gateway/config.py index 4b5a3bcb..1a98c8c2 100644 --- a/src/cmcp_gateway/config.py +++ b/src/cmcp_gateway/config.py @@ -5,6 +5,7 @@ import os from dataclasses import dataclass, field from enum import StrEnum +from pathlib import PurePosixPath, PureWindowsPath from typing import Any import yaml @@ -55,6 +56,20 @@ class Config: _KNOWN_ATTEST_KEYS = {"provider", "enforcement_mode", "validity_seconds", "staleness_policy"} +def _check_no_traversal(field_name: str, path_str: str) -> None: + """Reject paths that contain '..' components to prevent directory traversal (CONF-004).""" + for part in PurePosixPath(path_str).parts: + if part == "..": + raise ConfigError( + f"'{field_name}' must not contain '..' path components: {path_str!r}" + ) + for part in PureWindowsPath(path_str).parts: + if part == "..": + raise ConfigError( + f"'{field_name}' must not contain '..' path components: {path_str!r}" + ) + + def load_config(path: str) -> Config: """Load and validate cmcp-config.yaml. Raises ConfigError on invalid input.""" raw: dict[str, Any] @@ -114,6 +129,11 @@ def load_config(path: str) -> Config: dev_mode = os.environ.get("CMCP_DEV_MODE", "0") == "1" bearer_token = os.environ.get("CMCP_BEARER_TOKEN") or None + policy_bundle_path = raw.get("policy_bundle_path", "policy/") + catalog_path = raw.get("catalog_path", "catalog.json") + _check_no_traversal("policy_bundle_path", policy_bundle_path) + _check_no_traversal("catalog_path", catalog_path) + return Config( attestation=AttestationConfig( provider=provider, @@ -121,8 +141,8 @@ def load_config(path: str) -> Config: validity_seconds=validity_seconds, staleness_policy=staleness_policy, ), - policy_bundle_path=raw.get("policy_bundle_path", "policy/"), - catalog_path=raw.get("catalog_path", "catalog.json"), + policy_bundle_path=policy_bundle_path, + catalog_path=catalog_path, listen_addr=raw.get("listen_addr", "0.0.0.0:8443"), max_response_size_bytes=max_bytes, dev_mode=dev_mode, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index aa1e96f1..3df717a3 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -98,3 +98,34 @@ def test_non_mapping_config(config_file): def test_missing_file(): with pytest.raises(ConfigError, match="Cannot read"): load_config("/nonexistent/path/config.yaml") + + +# ── CONF-004: path traversal rejection ─────────────────────────────────────── + +def test_policy_bundle_path_traversal_rejected(config_file): + """CONF-004: '..' components in policy_bundle_path must be rejected.""" + path = config_file("policy_bundle_path: ../../etc/passwd\n") + with pytest.raises(ConfigError, match=r"\.\."): + load_config(path) + + +def test_catalog_path_traversal_rejected(config_file): + """CONF-004: '..' components in catalog_path must be rejected.""" + path = config_file("catalog_path: ../../../etc/shadow\n") + with pytest.raises(ConfigError, match=r"\.\."): + load_config(path) + + +def test_embedded_traversal_in_policy_path_rejected(config_file): + """CONF-004: embedded '..' (e.g. /safe/../etc) must also be rejected.""" + path = config_file("policy_bundle_path: /safe/../etc/passwd\n") + with pytest.raises(ConfigError, match=r"\.\."): + load_config(path) + + +def test_legitimate_absolute_path_accepted(config_file): + """CONF-004: absolute paths without '..' remain valid.""" + path = config_file("policy_bundle_path: /opt/cmcp/policy\ncatalog_path: /opt/cmcp/catalog.json\n") + cfg = load_config(path) + assert cfg.policy_bundle_path == "/opt/cmcp/policy" + assert cfg.catalog_path == "/opt/cmcp/catalog.json"