Skip to content

Commit 5fd09d3

Browse files
committed
fix(security): restrict tunnels.json file permissions
~/.mempalace/tunnels.json (introduced in #790) was created via plain open(..., "w") with no chmod, and its parent dir via os.makedirs() without mode=0o700. On Linux with default umask 022 both end up world-readable (0o644 / 0o755). Tunnels reveal cross-wing connections — which projects, people, and rooms the user has explicitly linked — so they are sensitive metadata that should not be readable by other local users on shared systems. Apply the same 0o700 / 0o600 pattern that #814 established for the other sensitive palace files. Chmod calls are wrapped in try/except (OSError, NotImplementedError) for Windows / unsupported-filesystem compatibility. Closes #1165
1 parent 7a75791 commit 5fd09d3

2 files changed

Lines changed: 47 additions & 1 deletion

File tree

mempalace/palace_graph.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,20 @@ def _save_tunnels(tunnels):
313313
Writes to ``tunnels.json.tmp`` then ``os.replace``s it into place, so
314314
a crash mid-write can never leave a partial/empty tunnels.json that
315315
silently wipes every tunnel on next read.
316+
317+
Also restricts the parent directory to 0o700 and the file to 0o600 —
318+
tunnels reveal cross-wing connections (which projects/people/rooms
319+
the user has explicitly linked) and should not be world-readable on
320+
shared Linux/multi-user systems. Matches the file-permission pattern
321+
established by #814 for the other sensitive palace files.
316322
"""
317-
os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True)
323+
parent = os.path.dirname(_TUNNEL_FILE)
324+
os.makedirs(parent, exist_ok=True)
325+
try:
326+
os.chmod(parent, 0o700)
327+
except (OSError, NotImplementedError):
328+
# Windows / unsupported filesystems — tolerate.
329+
pass
318330
tmp_path = _TUNNEL_FILE + ".tmp"
319331
with open(tmp_path, "w", encoding="utf-8") as f:
320332
json.dump(tunnels, f, indent=2)
@@ -325,6 +337,10 @@ def _save_tunnels(tunnels):
325337
# Not all filesystems (or Windows file handles) support fsync — tolerate.
326338
pass
327339
os.replace(tmp_path, _TUNNEL_FILE)
340+
try:
341+
os.chmod(_TUNNEL_FILE, 0o600)
342+
except (OSError, NotImplementedError):
343+
pass
328344

329345

330346
def _endpoint_key(wing: str, room: str) -> str:

tests/test_palace_graph_tunnels.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Tests for explicit tunnel helpers in mempalace.palace_graph."""
22

3+
import os
4+
import stat
5+
import sys
36
from unittest.mock import MagicMock, patch
47

58
import pytest
@@ -37,6 +40,33 @@ def test_save_and_load_round_trip(self, tmp_path, monkeypatch):
3740
palace_graph._save_tunnels(tunnels)
3841
assert palace_graph._load_tunnels() == tunnels
3942

43+
@pytest.mark.skipif(
44+
sys.platform == "win32",
45+
reason="POSIX file-permission bits only apply on Unix-like systems",
46+
)
47+
def test_save_tunnels_restricts_permissions(self, tmp_path, monkeypatch):
48+
"""Regression for #1165 — tunnels.json reveals cross-wing links and
49+
must not be world-readable on shared Linux/multi-user systems."""
50+
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
51+
palace_graph._save_tunnels(
52+
[
53+
{
54+
"id": "x",
55+
"source": {"wing": "a", "room": "r1"},
56+
"target": {"wing": "b", "room": "r2"},
57+
"label": "",
58+
}
59+
]
60+
)
61+
62+
file_mode = stat.S_IMODE(os.stat(tunnel_file).st_mode)
63+
assert file_mode == 0o600, f"tunnels.json mode is {oct(file_mode)}, expected 0o600"
64+
65+
parent_mode = stat.S_IMODE(os.stat(tunnel_file.parent).st_mode)
66+
assert (
67+
parent_mode == 0o700
68+
), f"tunnels.json parent dir mode is {oct(parent_mode)}, expected 0o700"
69+
4070

4171
class TestExplicitTunnels:
4272
def test_create_tunnel_deduplicates_reverse_order_and_updates_label(

0 commit comments

Comments
 (0)