From 2b30744c34aec084d04358d2c29d89fbee43c95b Mon Sep 17 00:00:00 2001 From: solyanviktor-star <233359899+solyanviktor-star@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:59:43 +0300 Subject: [PATCH] security(photon): create auth.json temp file with 0o600 atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- plugins/platforms/photon/auth.py | 33 ++++++++++++++++----- tests/plugins/platforms/photon/test_auth.py | 14 +++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 227b90d79dd0..8fbb87f57f7d 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -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]: diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 3e6208de2517..828d02b02219 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -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: