From bf6aba8285a2756315268a7fcb4e8aeea755d478 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Fri, 29 May 2026 00:59:10 -0700 Subject: [PATCH] fix(security): resolve symlink write-guard bypass on Python 3.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_write_blocked()` chained `Path(os.path.realpath(path)).resolve()`, but on Python <3.12 `Path.resolve()` uses a separate code path that can disagree with `os.path.realpath()` — symlinks pointing into blocked directories were not detected as blocked. Drop the redundant `.resolve()` call so `os.path.realpath` is the single source of truth for symlink resolution across all supported Python versions. --- src/gaia/security.py | 7 +++++-- tests/unit/test_security_edge_cases.py | 8 +++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/gaia/security.py b/src/gaia/security.py index 6f199670e..69961edfb 100644 --- a/src/gaia/security.py +++ b/src/gaia/security.py @@ -410,8 +410,11 @@ def is_write_blocked(self, path: str) -> Tuple[bool, str]: Tuple of (is_blocked, reason). If blocked, reason explains why. """ try: - real_path = Path(os.path.realpath(path)).resolve() - real_path_str = str(real_path) + # Use os.path.realpath exclusively for symlink resolution — do NOT + # chain Path.resolve(), which re-resolves on Python <3.12 via a + # separate code path and can disagree with realpath. + real_path_str = os.path.realpath(path) + real_path = Path(real_path_str) # Apply macOS /private normalization so /etc, /var/run, etc. match # the BLOCKED_DIRECTORIES entries (they're stored unprefixed). norm_path = os.path.normpath(_normalize_macos_symlinks(real_path_str)) diff --git a/tests/unit/test_security_edge_cases.py b/tests/unit/test_security_edge_cases.py index af1184ce9..8674b7528 100644 --- a/tests/unit/test_security_edge_cases.py +++ b/tests/unit/test_security_edge_cases.py @@ -344,12 +344,10 @@ def test_exception_during_path_resolution_returns_blocked(self, validator): assert is_blocked is True assert "unable to validate" in reason.lower() - def test_exception_from_path_resolve_returns_blocked(self, validator): - """When Path.resolve() raises, is_write_blocked returns (True, reason).""" + def test_exception_from_path_construction_returns_blocked(self, validator): + """When path construction raises, is_write_blocked returns (True, reason).""" with patch("os.path.realpath", return_value="/tmp/test.txt"): - with patch.object( - Path, "resolve", side_effect=RuntimeError("Resolve failed") - ): + with patch("os.path.normpath", side_effect=RuntimeError("Normpath failed")): is_blocked, reason = validator.is_write_blocked("/tmp/test.txt") assert is_blocked is True