mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
The predictable lock path was opened with Path.open("w"), following (and
truncating through) a pre-positioned symlink. Open it via os.open with
O_NOFOLLOW and no O_TRUNC, raising StoreLockError on a symlinked lock path.
108 lines
4.0 KiB
Python
108 lines
4.0 KiB
Python
"""Shared subscription credential store: secure writes and cross-provider locking."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import stat
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from strix.config import codex, grok, subscription_store
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def test_write_creates_owner_only_file(tmp_path: Path) -> None:
|
|
path = tmp_path / ".strix" / "subscription-auth.json"
|
|
subscription_store.write(path, {"grok": {"type": "oauth", "access": "a", "refresh": "r"}})
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
# No stray temp file is left behind.
|
|
assert not path.with_suffix(".json.tmp").exists()
|
|
|
|
|
|
def test_write_does_not_follow_a_symlink_at_target(tmp_path: Path) -> None:
|
|
store_dir = tmp_path / ".strix"
|
|
store_dir.mkdir()
|
|
outside = tmp_path / "attacker-target.json"
|
|
path = store_dir / "subscription-auth.json"
|
|
path.symlink_to(outside) # attacker pre-plants a symlink at the store path
|
|
|
|
subscription_store.write(path, {"grok": {"type": "oauth", "access": "a", "refresh": "r"}})
|
|
|
|
# The atomic rename replaced the symlink with a real file; nothing was
|
|
# written through it to the attacker-chosen location.
|
|
assert not path.is_symlink()
|
|
assert not outside.exists()
|
|
assert subscription_store.read(path)["grok"]["access"] == "a"
|
|
|
|
|
|
def test_providers_share_store_without_clobbering(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
store = tmp_path / ".strix" / "subscription-auth.json"
|
|
monkeypatch.setattr(codex, "AUTH_PATH", store)
|
|
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
|
|
|
codex.save_record({"type": "oauth", "access": "c", "refresh": "r", "account_id": "acct"})
|
|
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
|
|
|
data = subscription_store.read(store)
|
|
assert data["codex"]["access"] == "c"
|
|
assert data["grok"]["access"] == "g"
|
|
|
|
# Logging one provider out leaves the other's credential intact.
|
|
grok.logout()
|
|
remaining = subscription_store.read(store)
|
|
assert "grok" not in remaining
|
|
assert remaining["codex"]["access"] == "c"
|
|
|
|
|
|
def test_guard_is_reentrant(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
store = tmp_path / ".strix" / "subscription-auth.json"
|
|
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
|
# Persisting while already holding the guard must not deadlock — this mirrors
|
|
# a token refresh saving its new record inside the refresh critical section.
|
|
with subscription_store.guard(store):
|
|
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
|
record = grok.read_record()
|
|
assert record is not None
|
|
assert record["access"] == "g"
|
|
|
|
|
|
def test_mutation_aborts_when_lock_cannot_be_acquired(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
store = tmp_path / ".strix" / "subscription-auth.json"
|
|
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
|
|
|
def _no_lock(*_args: object, **_kwargs: object) -> None:
|
|
raise OSError("no locks available")
|
|
|
|
monkeypatch.setattr(fcntl, "flock", _no_lock)
|
|
|
|
# Rather than silently doing an unlocked read-modify-write, the store raises
|
|
# and writes nothing.
|
|
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()
|