mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(cli): write autoroute's secret-bearing files with mode 0600
commands.py wrote config.yaml (embeds the real proxy key) and Claude Code's settings.json (embeds the ephemeral proxy's master key) with plain open(), landing at the umask-derived default (commonly 0644) until a later chmod call caught up. That window, and the missed case where settings.json already exists (chmod never ran at all there), left a credential-bearing file readable by another local account. secure_create() fixes the mode via fchmod on the fd before any content is written, covering both the brand-new-file and already-exists cases, and commands.py/wizard.py now route their sensitive writes through it.
This commit is contained in:
parent
d4ad521845
commit
39f6618f93
4 changed files with 33 additions and 6 deletions
|
|
@ -23,6 +23,7 @@ from .process import (
|
|||
launch_proxy,
|
||||
poll_liveliness,
|
||||
read_pid_record,
|
||||
secure_create,
|
||||
stream_log,
|
||||
terminate,
|
||||
write_pid_record,
|
||||
|
|
@ -52,9 +53,8 @@ def _mint_and_embed_master_key() -> str:
|
|||
"master_key": master_key,
|
||||
}
|
||||
updated: dict[str, JsonValue] = {**generated, "general_settings": updated_settings}
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
with secure_create(CONFIG_PATH) as f:
|
||||
yaml.safe_dump(updated, f, sort_keys=False)
|
||||
CONFIG_PATH.chmod(0o600)
|
||||
return master_key
|
||||
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ def up() -> None:
|
|||
)
|
||||
merged = merge_claude_settings_static_token(original_settings, base_url, master_key)
|
||||
CLAUDE_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CLAUDE_SETTINGS_PATH, "w") as f:
|
||||
with secure_create(CLAUDE_SETTINGS_PATH) as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
|
||||
click.echo(f"litellm: ephemeral auto-router proxy up at {base_url} (pid {process.pid})")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import threading
|
|||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO, Iterator
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
|
@ -20,6 +21,28 @@ LOG_PATH = AUTOROUTE_DIR / "proxy.log"
|
|||
PID_RECORD_PATH = AUTOROUTE_DIR / "proxy.pid.json"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def secure_create(path: Path) -> Iterator[IO[str]]:
|
||||
"""Open path for writing with mode 0600 fixed up before any content is written.
|
||||
|
||||
A plain `open(path, "w")` creates a *new* file at the umask-derived default (commonly 0644)
|
||||
and leaves it world- or group-readable until a later `chmod` call catches up -- a real window
|
||||
in which a file holding a credential (a proxy master key, a Claude Code auth token) is readable
|
||||
by another local account. Passing the mode to `os.open` closes that window for a brand-new
|
||||
file, but `O_CREAT`'s mode argument is only applied on creation: if the file already exists
|
||||
(the common case for `~/.claude/settings.json`, which normally predates `lite autoroute up`)
|
||||
its old, broader permissions carry over untouched. `os.fchmod` right after opening -- before a
|
||||
single byte of the new content is written -- covers both cases.
|
||||
"""
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.fchmod(fd, 0o600)
|
||||
f: IO[str] = os.fdopen(fd, "w")
|
||||
try:
|
||||
yield f
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
class ProcessLaunchError(Exception):
|
||||
"""Raised when the ephemeral proxy subprocess fails to come up healthy."""
|
||||
|
||||
|
|
@ -151,6 +174,7 @@ __all__ = [
|
|||
"launch_proxy",
|
||||
"poll_liveliness",
|
||||
"read_pid_record",
|
||||
"secure_create",
|
||||
"stream_log",
|
||||
"terminate",
|
||||
"write_pid_record",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from .config import (
|
|||
parse_discovered_models,
|
||||
validate_config,
|
||||
)
|
||||
from .process import CONFIG_PATH
|
||||
from .process import CONFIG_PATH, secure_create
|
||||
|
||||
|
||||
def _is_interactive() -> bool:
|
||||
|
|
@ -113,9 +113,8 @@ def run_configure_wizard(ctx: click.Context) -> Path:
|
|||
|
||||
model_list = build_generated_model_list(config)
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
with secure_create(CONFIG_PATH) as f:
|
||||
yaml.safe_dump({"model_list": model_list}, f, sort_keys=False)
|
||||
CONFIG_PATH.chmod(0o600)
|
||||
|
||||
click.echo(f"\nWrote {CONFIG_PATH}")
|
||||
for tier, models in tiers.items():
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import stat
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
|
@ -90,6 +91,7 @@ class TestUpCommand:
|
|||
def fake_wait(self, timeout=None):
|
||||
captured["settings"] = json.loads(claude_settings_path.read_text())
|
||||
captured["backup_existed"] = backup_path.exists()
|
||||
captured["settings_mode"] = stat.S_IMODE(claude_settings_path.stat().st_mode)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("threading.Event.wait", fake_wait)
|
||||
|
|
@ -102,6 +104,7 @@ class TestUpCommand:
|
|||
assert captured["settings"]["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:54321"
|
||||
assert captured["settings"]["env"]["ANTHROPIC_AUTH_TOKEN"] == "fixed-master-key"
|
||||
assert "apiKeyHelper" not in captured["settings"]
|
||||
assert captured["settings_mode"] == 0o600
|
||||
|
||||
assert terminate_calls == [99999]
|
||||
assert not pid_record_path.exists()
|
||||
|
|
@ -110,6 +113,7 @@ class TestUpCommand:
|
|||
|
||||
written_config = yaml.safe_load(config_path.read_text())
|
||||
assert written_config["general_settings"]["master_key"] == "fixed-master-key"
|
||||
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
||||
|
||||
def test_surfaces_clean_error_and_cleans_up_when_health_check_fails(self, monkeypatch, tmp_path):
|
||||
config_path, _log_path, claude_settings_path, backup_path, pid_record_path = _patch_paths(monkeypatch, tmp_path)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue