Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/cmcp_gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -114,15 +129,20 @@ 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,
enforcement_mode=enforcement_mode,
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,
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading