From 3d8c6101b6d2dfb301ad851303f3060cedeaff95 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 11:44:24 -0700 Subject: [PATCH] [eric] security: make the 9router state dir owner-only, its db.json held live tokens at 0644 --- backend/apps/nine_router/process.py | 21 +++++++ .../tests/test_router_data_dir_permissions.py | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 backend/tests/test_router_data_dir_permissions.py diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 79eea7ad..88e011df 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -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() diff --git a/backend/tests/test_router_data_dir_permissions.py b/backend/tests/test_router_data_dir_permissions.py new file mode 100644 index 00000000..68d75271 --- /dev/null +++ b/backend/tests/test_router_data_dir_permissions.py @@ -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()