security(photon): create auth.json temp file with 0o600 atomically

_save_auth() wrote the bearer token with tmp.open('w') — created at
process umask (typically 0o644) — and only chmod'ed to 0o600 after the
write, leaving a window where the token sat world-readable. The temp
name was also fixed and predictable (auth.json.tmp), so it could be
pre-planted (symlink attack).

Create the temp file with os.open(O_WRONLY|O_CREAT|O_EXCL, 0o600) and a
per-process random suffix, fsync before the atomic replace, and clean
the temp file up on failure. Mirrors hermes_cli/auth.py:_save_auth_store
(#19673, #21148), which hardened the same pattern in the core writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
solyanviktor-star 2026-07-07 21:59:43 +03:00 committed by Teknium
parent 9044c4b836
commit 2b30744c34
2 changed files with 40 additions and 7 deletions

View file

@ -40,7 +40,9 @@ import json
import logging
import os
import re
import stat
import time
import uuid
from base64 import b64encode
from dataclasses import dataclass
from pathlib import Path
@ -109,14 +111,31 @@ def _load_auth() -> Dict[str, Any]:
def _save_auth(data: Dict[str, Any]) -> None:
path = _auth_json_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, sort_keys=True)
# Per-process random temp suffix avoids collisions between concurrent
# writers and stale leftovers from a crashed prior write.
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
# Create with 0o600 atomically via os.open(O_EXCL) + fdopen: the old
# open() → write → chmod() sequence left a window where the bearer
# token sat world-readable at process umask (typically 0o644), and the
# predictable temp name could be pre-planted (symlink attack). Mirrors
# hermes_cli/auth.py:_save_auth_store (#19673, #21148).
fd = os.open(
str(tmp),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
try:
os.chmod(tmp, 0o600)
except OSError:
pass
tmp.replace(path)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, sort_keys=True)
fh.flush()
os.fsync(fh.fileno())
tmp.replace(path)
except BaseException:
try:
tmp.unlink()
except OSError:
pass
raise
def load_photon_token() -> Optional[str]:

View file

@ -71,6 +71,20 @@ def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
@pytest.mark.skipif(os.name != "posix", reason="POSIX mode bits only")
def test_save_auth_never_world_readable(tmp_hermes_home: Path) -> None:
"""auth.json must be created 0o600 — no window at process umask."""
photon_auth.store_photon_token("secret-token")
mode = (tmp_hermes_home / "auth.json").stat().st_mode & 0o777
assert mode == 0o600
def test_save_auth_leaves_no_temp_files(tmp_hermes_home: Path) -> None:
photon_auth.store_photon_token("secret-token")
leftovers = [p.name for p in tmp_hermes_home.iterdir() if p.name != "auth.json"]
assert leftovers == []
def test_store_project_credentials_round_trip(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None: