Skip to content

Commit 1db9049

Browse files
Previos-Pr-work
1 parent 9439ecb commit 1db9049

2 files changed

Lines changed: 61 additions & 11 deletions

File tree

py/tests/test_data_model.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,39 @@ def test_long_name_hash_fallback_round_trips(self):
115115
loaded = self.backend.load_env(long_eid)
116116
self.assertEqual(loaded["jsons"], _env()["jsons"])
117117

118+
def test_save_ignores_path_traversal_ids(self):
119+
"""A crafted id like '../evil' cannot write outside env_path."""
120+
parent = os.path.dirname(self.env_path)
121+
before = set(os.listdir(parent))
122+
for eid in ("../evil", "subdir/../evil", "/etc/evil"):
123+
self.backend.save_env(eid, _env())
124+
self.assertEqual(set(os.listdir(parent)) - before, set())
125+
base = os.path.abspath(self.env_path)
126+
for name in os.listdir(self.env_path):
127+
resolved = os.path.abspath(os.path.join(self.env_path, name))
128+
self.assertTrue(resolved.startswith(base + os.sep))
129+
130+
def test_traversal_id_round_trips_within_env_path(self):
131+
"""A crafted id is sanitised consistently across save/exists/list/load."""
132+
self.backend.save_env("../evil", _env())
133+
self.assertTrue(self.backend.env_exists("../evil"))
134+
self.assertEqual(self.backend.list_envs(), [".._evil"])
135+
self.assertEqual(self.backend.load_env("../evil"), _env())
136+
137+
def test_list_skips_unreadable_hash_files(self):
138+
"""Malformed hash_<64>.json files are ignored, not raised, by list_envs."""
139+
hex64 = "a" * 64
140+
with open(
141+
os.path.join(self.env_path, "hash_{0}.json".format(hex64)), "w"
142+
) as fn:
143+
fn.write("{not valid json")
144+
with open(
145+
os.path.join(self.env_path, "hash_{0}.json".format("b" * 64)), "w"
146+
) as fn:
147+
fn.write(json.dumps({"jsons": {}, "reload": {}}))
148+
self.backend.save_env("main", _env())
149+
self.assertEqual(self.backend.list_envs(), ["main"])
150+
118151

119152
class TestJSONStoreNoPath(unittest.TestCase):
120153
"""JSONStore(None): persistence disabled (in-memory-only mode)."""

py/visdom/data_model/json_store.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,23 @@ def __init__(self, env_path):
3030
"""Create a store rooted at ``env_path`` (``None`` disables persistence)."""
3131
self.env_path = env_path
3232

33+
def _safe_eid(self, eid):
34+
"""Sanitise ``eid`` into the id used for on-disk filenames.
35+
36+
Strips surrounding whitespace and neutralises path separators (via
37+
``escape_eid``) so a crafted id such as ``../evil`` cannot escape
38+
``env_path``. Saves, loads, deletes and existence checks all funnel
39+
through this so they agree on the file a given ``eid`` maps to.
40+
"""
41+
return escape_eid(eid.strip())
42+
3343
def _primary_path(self, eid):
3444
"""Return the canonical ``<env_path>/<eid>.json`` path for ``eid``.
3545
3646
Returns ``None`` if the resolved path would escape ``env_path`` (guards
3747
against path traversal via a crafted env id).
3848
"""
39-
safe_eid = escape_eid(eid.strip())
49+
safe_eid = self._safe_eid(eid)
4050
base = os.path.abspath(self.env_path)
4151
path = os.path.abspath(os.path.join(base, "{0}.json".format(safe_eid)))
4252
try:
@@ -47,7 +57,7 @@ def _primary_path(self, eid):
4757

4858
def _hash_path(self, eid):
4959
"""Return the ``hash_<sha256>.json`` fallback path for ``eid``."""
50-
safe_eid = escape_eid(eid.strip())
60+
safe_eid = self._safe_eid(eid)
5161
hashed_id = hashlib.sha256(safe_eid.encode("utf-8")).hexdigest()
5262
return os.path.join(self.env_path, "hash_{0}.json".format(hashed_id))
5363

@@ -63,22 +73,29 @@ def _resolve_existing(self, eid):
6373

6474
def save_env(self, eid, env_data):
6575
"""Persist a single environment; return ``True`` if written, else ``False``."""
66-
if self.env_path is None:
67-
return False
68-
serialize_env({eid: env_data}, [eid], env_path=self.env_path)
69-
return True
76+
return bool(self.save_envs({eid: env_data}, [eid]))
7077

7178
def save_envs(self, state, eids):
72-
"""Persist the named subset of ``state``; return the ids actually written."""
79+
"""Persist the named subset of ``state``; return the ids actually written.
80+
81+
Each id is sanitised (see :meth:`_safe_eid`) before it becomes a
82+
filename, so a crafted id cannot write outside ``env_path``. The real
83+
(unsanitised) ids are returned, matching how callers refer to them.
84+
"""
7385
if self.env_path is None:
7486
return []
75-
return serialize_env(state, eids, env_path=self.env_path)
87+
written = []
88+
for eid in eids:
89+
if eid not in state:
90+
continue
91+
safe_eid = self._safe_eid(eid)
92+
serialize_env({safe_eid: state[eid]}, [safe_eid], env_path=self.env_path)
93+
written.append(eid)
94+
return written
7695

7796
def save_all(self, state):
7897
"""Persist every environment in ``state``; return the ids written."""
79-
if self.env_path is None:
80-
return []
81-
return serialize_env(state, list(state.keys()), env_path=self.env_path)
98+
return self.save_envs(state, list(state.keys()))
8299

83100
def load_env(self, eid):
84101
"""Read one environment by ``eid``; return ``{}`` if it is absent."""

0 commit comments

Comments
 (0)