From 801a1ecde291821ef4b77b4d5d09c054d6b95f16 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 16 Sep 2026 16:26:49 -0500 Subject: [PATCH 1/2] fix: resolve both operands in metadata path-within-root checks --- .../eybond_local/metadata/local_metadata.py | 12 +++--- .../eybond_local/metadata/profile_loader.py | 6 +-- .../metadata/register_schema_loader.py | 6 +-- tests/test_local_metadata.py | 39 +++++++++++++++++++ 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/custom_components/eybond_local/metadata/local_metadata.py b/custom_components/eybond_local/metadata/local_metadata.py index bc103b9b..dbd5b76b 100644 --- a/custom_components/eybond_local/metadata/local_metadata.py +++ b/custom_components/eybond_local/metadata/local_metadata.py @@ -270,11 +270,7 @@ def _ensure_can_write(path: Path, root: Path, *, overwrite: bool) -> None: def _is_within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root.resolve()) - except ValueError: - return False - return True + return path.resolve().is_relative_to(root.resolve()) def _local_override_details( @@ -303,7 +299,11 @@ def _local_override_details( return { "exists": False, "path": str(path), - "status": f"No active local override. Create {path.relative_to(root.parent)} to override the built-in {kind}.", + "status": ( + "No active local override. Create " + f"{path.resolve().relative_to(root.parent.resolve())} " + f"to override the built-in {kind}." + ), } diff --git a/custom_components/eybond_local/metadata/profile_loader.py b/custom_components/eybond_local/metadata/profile_loader.py index 9a3623d5..e6c6f6a8 100644 --- a/custom_components/eybond_local/metadata/profile_loader.py +++ b/custom_components/eybond_local/metadata/profile_loader.py @@ -536,11 +536,7 @@ def _optional_str(value: Any) -> str | None: def _is_within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root.resolve()) - except ValueError: - return False - return True + return path.resolve().is_relative_to(root.resolve()) def _merge_raw_profile( diff --git a/custom_components/eybond_local/metadata/register_schema_loader.py b/custom_components/eybond_local/metadata/register_schema_loader.py index c5955cf2..47fa1ccc 100644 --- a/custom_components/eybond_local/metadata/register_schema_loader.py +++ b/custom_components/eybond_local/metadata/register_schema_loader.py @@ -419,11 +419,7 @@ def _optional_str(value: Any) -> str | None: def _is_within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root.resolve()) - except ValueError: - return False - return True + return path.resolve().is_relative_to(root.resolve()) def _merge_raw_schema( diff --git a/tests/test_local_metadata.py b/tests/test_local_metadata.py index e9f9c7f2..369173bb 100644 --- a/tests/test_local_metadata.py +++ b/tests/test_local_metadata.py @@ -14,6 +14,7 @@ from custom_components.eybond_local.metadata.local_metadata import ( + _is_within_root as local_metadata_is_within_root, clear_local_metadata_loader_caches, create_local_profile_draft, create_local_schema_draft, @@ -24,6 +25,12 @@ resolve_local_metadata_rollback_paths, rollback_local_metadata_overrides, ) +from custom_components.eybond_local.metadata.profile_loader import ( + _is_within_root as profile_loader_is_within_root, +) +from custom_components.eybond_local.metadata.register_schema_loader import ( + _is_within_root as register_schema_loader_is_within_root, +) from custom_components.eybond_local.metadata.smartess_draft import ( create_smartess_known_family_draft, resolve_smartess_known_family_draft_plan, @@ -153,6 +160,38 @@ def _find_item(raw_items: list[dict[str, object]], key: str) -> dict[str, object class LocalMetadataTests(unittest.TestCase): + def test_is_within_root_accepts_symlink_alias_forms(self) -> None: + """Containment must survive mixed symlink forms (e.g. /var vs /private/var).""" + helpers = ( + local_metadata_is_within_root, + profile_loader_is_within_root, + register_schema_loader_is_within_root, + ) + with tempfile.TemporaryDirectory() as temp_dir: + base = Path(temp_dir) + real_root = base / "real" + real_root.mkdir() + alias_root = base / "alias" + alias_root.symlink_to(real_root.resolve()) + child = real_root / "nested" / "item.json" + child.parent.mkdir(parents=True) + child.write_text("{}", encoding="utf-8") + alias_child = alias_root / "nested" / "item.json" + outside = base / "outside.json" + outside.write_text("{}", encoding="utf-8") + + for helper in helpers: + with self.subTest(helper=helper.__module__): + self.assertTrue(helper(child, real_root)) + self.assertTrue(helper(child, alias_root)) + self.assertTrue(helper(alias_child, real_root)) + self.assertTrue(helper(alias_child, alias_root)) + # Mixed: unresolved alias path vs resolved real root (and reverse). + self.assertTrue(helper(alias_child, real_root.resolve())) + self.assertTrue(helper(child.resolve(), alias_root)) + self.assertFalse(helper(outside, real_root)) + self.assertFalse(helper(outside, alias_root)) + def test_detects_when_one_draft_name_overrides_builtin_metadata(self) -> None: self.assertTrue(draft_activates_automatically("smg_modbus.json", None)) self.assertTrue(draft_activates_automatically("smg_modbus.json", "smg_modbus.json")) From 25563af4f1c0a65be7695d88a0aa39709c0452ca Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Mon, 21 Sep 2026 18:10:10 -0500 Subject: [PATCH 2/2] test: reject symlink escape in metadata path-within-root --- tests/test_local_metadata.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_local_metadata.py b/tests/test_local_metadata.py index 369173bb..0a79b19e 100644 --- a/tests/test_local_metadata.py +++ b/tests/test_local_metadata.py @@ -192,6 +192,26 @@ def test_is_within_root_accepts_symlink_alias_forms(self) -> None: self.assertFalse(helper(outside, real_root)) self.assertFalse(helper(outside, alias_root)) + def test_is_within_root_rejects_symlink_escape(self) -> None: + """In-root symlink to a path outside root must not count as contained.""" + helpers = ( + local_metadata_is_within_root, + profile_loader_is_within_root, + register_schema_loader_is_within_root, + ) + with tempfile.TemporaryDirectory() as temp_dir: + base = Path(temp_dir).resolve() + root = base / "root" + root.mkdir() + secret = base / "secret" + secret.write_text("{}", encoding="utf-8") + link_out = root / "link_out" + link_out.symlink_to(Path("..") / "secret") + + for helper in helpers: + with self.subTest(helper=helper.__module__): + self.assertFalse(helper(link_out, root)) + def test_detects_when_one_draft_name_overrides_builtin_metadata(self) -> None: self.assertTrue(draft_activates_automatically("smg_modbus.json", None)) self.assertTrue(draft_activates_automatically("smg_modbus.json", "smg_modbus.json"))