diff --git a/strix/config/subscription_store.py b/strix/config/subscription_store.py index cfc9d745..d8be8625 100644 --- a/strix/config/subscription_store.py +++ b/strix/config/subscription_store.py @@ -129,7 +129,15 @@ def _acquire_flock(path: Path) -> TextIOWrapper: raise StoreLockError(msg) from exc lock_path = path.with_suffix(".lock") lock_path.parent.mkdir(parents=True, exist_ok=True) - handle = lock_path.open("w") + # O_NOFOLLOW rejects a pre-positioned symlink at the predictable lock path + # (so an attacker can't redirect the open), and no O_TRUNC since the lock + # file is only an flock anchor whose contents we never use. + try: + fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + except OSError as exc: + msg = f"could not open lock file {lock_path}: {exc}" + raise StoreLockError(msg) from exc + handle = os.fdopen(fd, "r+") try: while True: try: diff --git a/tests/test_subscription_store.py b/tests/test_subscription_store.py index 6f8a8be7..a3224d13 100644 --- a/tests/test_subscription_store.py +++ b/tests/test_subscription_store.py @@ -88,3 +88,20 @@ def test_mutation_aborts_when_lock_cannot_be_acquired( with pytest.raises(subscription_store.StoreLockError): grok.save_record({"type": "oauth", "access": "g", "refresh": "r"}) assert not store.exists() + + +def test_lock_file_rejects_a_pre_positioned_symlink( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + store_dir = tmp_path / ".strix" + store_dir.mkdir() + store = store_dir / "subscription-auth.json" + monkeypatch.setattr(grok, "AUTH_PATH", store) + # Attacker pre-plants a symlink where the lock file would be created. + outside = tmp_path / "attacker-target" + store.with_suffix(".lock").symlink_to(outside) + + with pytest.raises(subscription_store.StoreLockError): + grok.save_record({"type": "oauth", "access": "g", "refresh": "r"}) + # The symlink target was never created/truncated through the lock open. + assert not outside.exists()