refactor(secrets): every private-credential file is written by utils.atomic_json_write(mode=0o600)
Ten hand-rolled "write a token file safely" routines each carried a
different subset of {0600-on-create, fsync, atomic_replace, parent-0700
guard, BaseException cleanup}. Two of them (iron_proxy state files,
the exchanged-JWT store) still opened the temp file at process umask
and chmod'ed afterwards - the exact TOCTOU window the others document
as fixed. None of the bare-os.replace copies got atomic_replace's
Windows-contention retry or EXDEV fallback.
utils gains fsync_dir= (absorbs auth.py's dir fsync), atomic_write_bytes
(vault blob) and mode= on atomic_write_text; the ten sites become 1-3
line callers. mkstemp creates the temp file O_EXCL at 0600 regardless of
umask, so the payload is never umask-readable.
Behavior change: iron_proxy proxy.yaml/mappings.json and the exchanged-JWT
store are now 0600 from creation and fsync'd; every credential write goes
through atomic_replace (symlink-preserving, Windows retry, EXDEV copy).
auth_nous shared store now uses atomic_replace too (it forced os.replace
with no recorded reason). secret_sources cache parent-0700 goes through
the guarded secure_parent_dir instead of an unguarded chmod.
This commit is contained in:
@@ -29,6 +29,8 @@ from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from utils import atomic_json_write, atomic_write_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pinned: never auto-resolve "latest" — the YAML schema may change between releases.
|
||||
@@ -577,21 +579,15 @@ def ensure_audit_log(audit_path: Path) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
def _write_state_file_atomic(state: Path, name: str, dump) -> Path:
|
||||
"""0600 temp file + atomic replace: the file holds proxy tokens; chmod-after-replace would be a world-readable TOCTOU window."""
|
||||
tmp_path = state / f".{name}.tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
dump(f)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, state / name)
|
||||
return state / name
|
||||
|
||||
|
||||
def write_proxy_config(config: Dict) -> Path:
|
||||
"""Serialize the config dict to ``<hermes_home>/proxy/proxy.yaml`` (safe_dump, no Python tags)."""
|
||||
"""Serialize the config dict to ``<hermes_home>/proxy/proxy.yaml`` (safe_dump, no Python tags).
|
||||
|
||||
The file holds proxy tokens: written 0600 from creation, never at process umask."""
|
||||
if (yaml := _yaml()) is None:
|
||||
raise RuntimeError("PyYAML is required to write the iron-proxy config but is not installed.")
|
||||
return _write_state_file_atomic(_proxy_state_dir(), "proxy.yaml", lambda f: yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False))
|
||||
path = _proxy_state_dir() / "proxy.yaml"
|
||||
atomic_write_text(path, yaml.safe_dump(config, default_flow_style=False, sort_keys=False), mode=0o600)
|
||||
return path
|
||||
|
||||
|
||||
def write_mappings(mappings: List[TokenMapping]) -> Path:
|
||||
@@ -600,7 +596,9 @@ def write_mappings(mappings: List[TokenMapping]) -> Path:
|
||||
"proxy_token": m.proxy_token, "env_name": m.real_env_name, "upstream_hosts": list(m.upstream_hosts),
|
||||
"match_headers": list(m.match_headers), "alias_env_names": list(m.alias_env_names),
|
||||
} for m in mappings]}
|
||||
return _write_state_file_atomic(_proxy_state_dir(), "mappings.json", lambda f: json.dump(payload, f, indent=2))
|
||||
path = _proxy_state_dir() / "mappings.json"
|
||||
atomic_json_write(path, payload, mode=0o600)
|
||||
return path
|
||||
|
||||
|
||||
def load_mappings() -> List[TokenMapping]:
|
||||
|
||||
Reference in New Issue
Block a user