[eric] security: make the 9router state dir owner-only, its db.json held live tokens at 0644

This commit is contained in:
ciregenz
2026-07-31 11:44:24 -07:00
parent 6f32861b41
commit 3d8c6101b6
2 changed files with 77 additions and 0 deletions
+21
View File
@@ -17,6 +17,7 @@ import os
import secrets
import shutil
import socket
import stat
import subprocess
import tempfile
import time
@@ -115,6 +116,25 @@ def p_nine_router_data_dir() -> str:
return os.path.join(os.path.expanduser("~"), ".9router")
def harden_data_dir_permissions() -> None:
"""Make the 9Router state dir owner-only. Its db.json holds live subscription access AND refresh
tokens in plaintext and 9Router writes it 0644, so on a shared machine any other local account
can read them. We tighten the DIRECTORY rather than the file because 9Router rewrites db.json on
every token refresh, which would drop a chmod on the file itself within the hour."""
if os.name == "nt":
return
data_dir = p_nine_router_data_dir()
try:
if not os.path.isdir(data_dir):
return
current = stat.S_IMODE(os.stat(data_dir).st_mode)
if current & 0o077:
os.chmod(data_dir, 0o700)
logger.info("tightened 9router data dir from %s to 0700", oct(current))
except OSError:
logger.warning("could not tighten 9router data dir permissions", exc_info=True)
p_cli_token_cache: str | None = None
@@ -353,6 +373,7 @@ async def ensure_running():
p_start_lock = asyncio.Lock()
async with p_start_lock:
await p_ensure_running_impl()
harden_data_dir_permissions()
# Arm both healers the moment the router becomes a live dependency; users who never route through it never spawn them.
if is_running():
start_watchdog()
@@ -0,0 +1,56 @@
"""9Router's state dir must be owner-only.
Its db.json carries live subscription access AND refresh tokens in plaintext, and 9Router writes
that file 0644. On a shared machine every other local account could read them. We tighten the
directory rather than the file because 9Router rewrites db.json on every token refresh, so a chmod
on the file itself would be undone within the hour.
Run:
cd backend && .venv/bin/python -m pytest tests/test_router_data_dir_permissions.py -v
"""
import os
import stat
import pytest
import backend.apps.nine_router.process as process
@pytest.fixture
def p_data_dir(tmp_path, monkeypatch):
d = tmp_path / "9router"
d.mkdir()
monkeypatch.setenv("DATA_DIR", str(d))
return d
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits only")
def test_group_and_world_readable_dir_is_tightened(p_data_dir):
os.chmod(p_data_dir, 0o755)
process.harden_data_dir_permissions()
assert stat.S_IMODE(os.stat(p_data_dir).st_mode) == 0o700
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits only")
def test_tokens_are_unreadable_by_others_afterwards(p_data_dir):
"""The property that actually matters, stated as the attacker sees it: no bit outside the owner."""
os.chmod(p_data_dir, 0o755)
(p_data_dir / "db.json").write_text('{"providerConnections":[]}')
process.harden_data_dir_permissions()
assert stat.S_IMODE(os.stat(p_data_dir).st_mode) & 0o077 == 0
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits only")
def test_already_tight_dir_is_left_alone(p_data_dir):
os.chmod(p_data_dir, 0o700)
before = os.stat(p_data_dir).st_mtime_ns
process.harden_data_dir_permissions()
assert stat.S_IMODE(os.stat(p_data_dir).st_mode) == 0o700
assert os.stat(p_data_dir).st_mtime_ns == before
def test_missing_dir_does_not_raise(tmp_path, monkeypatch):
"""Runs before 9Router has ever started, so the dir legitimately may not exist yet."""
monkeypatch.setenv("DATA_DIR", str(tmp_path / "nope"))
process.harden_data_dir_permissions()