mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
9044c4b836
commit
2b30744c34
2 changed files with 40 additions and 7 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue