mirror of
https://github.com/andrewyng/openworker.git
synced 2026-08-30 22:53:41 +00:00
Merge pull request #291 from Mr-Neutr0n/security/secrets-created-private
security: create secret files private, never chmod them after (#143)
This commit is contained in:
+38
-16
@@ -14,6 +14,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -88,21 +89,50 @@ def _restrict_to_user(path: Path, *, is_dir: bool) -> None:
|
|||||||
os.chmod(path, 0o700 if is_dir else 0o600)
|
os.chmod(path, 0o700 if is_dir else 0o600)
|
||||||
|
|
||||||
|
|
||||||
def write_private_text(path: str | Path, content: str) -> Path:
|
def _atomic_private_write(target: Path, content: str) -> Path:
|
||||||
"""Atomically write a user-only text file using the SecretStore's OS protections."""
|
"""Write `content` to `target` atomically, never exposing it through a readable temp.
|
||||||
target = Path(path).expanduser()
|
|
||||||
|
The temp file used to be created by `Path.write_text` and only chmod-ed afterwards, so
|
||||||
|
the plaintext sat on disk at the umask default (0644 on a normal box) for the length of
|
||||||
|
the write — readable by every local process and by anything backing the directory up.
|
||||||
|
That is issue #143; the same pattern was in both writers here.
|
||||||
|
|
||||||
|
`tempfile.mkstemp` creates with 0600 and O_EXCL before a byte is written, which also
|
||||||
|
removes the fixed `<name>.tmp` filename. That name was predictable, so a local attacker
|
||||||
|
could pre-create it as a symlink and have the write land wherever the link pointed.
|
||||||
|
|
||||||
|
Windows gets no mode bits from mkstemp, so the ACL is applied to the still-empty file
|
||||||
|
before the content goes in.
|
||||||
|
"""
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
_restrict_to_user(target.parent, is_dir=True)
|
_restrict_to_user(target.parent, is_dir=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
tmp = target.with_name(target.name + ".tmp")
|
|
||||||
tmp.write_text(content, encoding="utf-8")
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
_restrict_to_user(tmp, is_dir=False)
|
dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp"
|
||||||
os.replace(tmp, target)
|
)
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
_restrict_to_user(tmp, is_dir=False)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
os.replace(tmp, target)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_text(path: str | Path, content: str) -> Path:
|
||||||
|
"""Atomically write a user-only text file using the SecretStore's OS protections."""
|
||||||
|
return _atomic_private_write(Path(path).expanduser(), content)
|
||||||
|
|
||||||
|
|
||||||
class SecretStore:
|
class SecretStore:
|
||||||
"""File-backed secret store. Reads resolve `${VAR}` refs; status never leaks values."""
|
"""File-backed secret store. Reads resolve `${VAR}` refs; status never leaks values."""
|
||||||
|
|
||||||
@@ -182,12 +212,4 @@ class SecretStore:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _write(self, store: dict[str, Any]) -> None:
|
def _write(self, store: dict[str, Any]) -> None:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
_atomic_private_write(self.path, json.dumps(store, indent=2))
|
||||||
try:
|
|
||||||
_restrict_to_user(self.path.parent, is_dir=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
|
||||||
tmp.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
|
||||||
_restrict_to_user(tmp, is_dir=False)
|
|
||||||
os.replace(tmp, self.path)
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Secrets must never touch the disk world-readable, even briefly (#143).
|
||||||
|
|
||||||
|
The write used to be: create the temp with `Path.write_text` (umask default, 0644 on a
|
||||||
|
normal box), then `chmod 0600`, then rename. The plaintext existed at 0644 for the length
|
||||||
|
of the write, which is readable by every other local process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from coworker import secrets as secrets_mod
|
||||||
|
from coworker.secrets import SecretStore, write_private_text
|
||||||
|
|
||||||
|
posix_only = pytest.mark.skipif(
|
||||||
|
sys.platform == "win32", reason="POSIX mode bits; Windows uses the icacls ACL path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_written_secret_file_is_user_only(tmp_path):
|
||||||
|
store = SecretStore(tmp_path / "secrets.json")
|
||||||
|
store.put("openai", {"api_key": "sk-live-secret"})
|
||||||
|
assert stat.S_IMODE((tmp_path / "secrets.json").stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_write_private_text_is_user_only(tmp_path):
|
||||||
|
path = write_private_text(tmp_path / "token.txt", "sk-live-secret")
|
||||||
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
@posix_only
|
||||||
|
def test_temp_file_is_never_group_or_world_readable_mid_write(tmp_path, monkeypatch):
|
||||||
|
"""The regression itself.
|
||||||
|
|
||||||
|
Hooks `_restrict_to_user`, which both the old and the new writer call, and samples the
|
||||||
|
temp's mode at that moment. Old order was write_text (umask default) -> chmod 0600 ->
|
||||||
|
rename, so the plaintext was on disk at 0644 first and this observes it. New order
|
||||||
|
creates the file 0600 and empty, so the same sample sees 0600.
|
||||||
|
"""
|
||||||
|
observed = {}
|
||||||
|
real = secrets_mod._restrict_to_user
|
||||||
|
|
||||||
|
def spy(path, *, is_dir):
|
||||||
|
if not is_dir and path.exists():
|
||||||
|
observed[path.name] = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
return real(path, is_dir=is_dir)
|
||||||
|
|
||||||
|
monkeypatch.setattr(secrets_mod, "_restrict_to_user", spy)
|
||||||
|
SecretStore(tmp_path / "secrets.json").put("openai", {"api_key": "sk-live"})
|
||||||
|
|
||||||
|
assert observed, "expected the writer to restrict a temp file"
|
||||||
|
for name, mode in observed.items():
|
||||||
|
assert mode & (stat.S_IRGRP | stat.S_IROTH) == 0, (
|
||||||
|
f"{name} existed at {oct(mode)} while holding the plaintext"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_temp_file_is_left_behind(tmp_path):
|
||||||
|
store = SecretStore(tmp_path / "secrets.json")
|
||||||
|
store.put("openai", {"api_key": "sk-live"})
|
||||||
|
leftovers = [p.name for p in tmp_path.iterdir() if p.name != "secrets.json"]
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_round_trips(tmp_path):
|
||||||
|
store = SecretStore(tmp_path / "secrets.json")
|
||||||
|
store.put("openai", {"api_key": "sk-live"})
|
||||||
|
store.put("anthropic", {"api_key": "sk-ant"})
|
||||||
|
on_disk = json.loads((tmp_path / "secrets.json").read_text())
|
||||||
|
assert on_disk["openai"]["api_key"] == "sk-live"
|
||||||
|
assert on_disk["anthropic"]["api_key"] == "sk-ant"
|
||||||
|
assert SecretStore(tmp_path / "secrets.json").get("openai")["api_key"] == "sk-live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_write_leaves_the_previous_file_intact(tmp_path, monkeypatch):
|
||||||
|
path = tmp_path / "secrets.json"
|
||||||
|
store = SecretStore(path)
|
||||||
|
store.put("openai", {"api_key": "sk-original"})
|
||||||
|
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr(secrets_mod.os, "replace", boom)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
store.put("openai", {"api_key": "sk-replacement"})
|
||||||
|
|
||||||
|
assert json.loads(path.read_text())["openai"]["api_key"] == "sk-original"
|
||||||
|
assert [p.name for p in tmp_path.iterdir()] == ["secrets.json"], "temp must be cleaned up"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hostile_preexisting_temp_name_cannot_redirect_the_write(tmp_path):
|
||||||
|
"""The old fixed `<name>.tmp` was predictable; a symlink there redirected the write."""
|
||||||
|
victim = tmp_path / "victim.txt"
|
||||||
|
victim.write_text("do not clobber")
|
||||||
|
decoy = tmp_path / "secrets.json.tmp"
|
||||||
|
try:
|
||||||
|
decoy.symlink_to(victim)
|
||||||
|
except (OSError, NotImplementedError):
|
||||||
|
pytest.skip("symlinks unavailable")
|
||||||
|
|
||||||
|
SecretStore(tmp_path / "secrets.json").put("openai", {"api_key": "sk-live"})
|
||||||
|
assert victim.read_text() == "do not clobber"
|
||||||
Reference in New Issue
Block a user