Skip to content

Commit 75468a2

Browse files
committed
fix: validate /api/set-workspace path — reject traversal, symlink escape, non-existent (closes #15)
POST /api/set-workspace accepted any string in body.path, ran tilde expansion, and stored it in a module-global override consumed by every subsequent file lookup. Anyone reaching the endpoint (including a hostile JS payload — see XSS issue #11) could repoint the app at /etc, /, ~/.ssh, or any other directory; the dashboard would then serve files from there as if they were Cursor chat data. Symlink-based escape (e.g. /tmp/cursor-link → /) bypassed any naive startswith-style check. New utils/path_validation.py with validate_workspace_path(raw): - Expands ~/. - os.path.realpath() — collapses `..` AND resolves symlinks in one step. Both classes of escape become equivalent to the canonical real path on disk; downstream marker check then operates on the truth. - Rejects with WorkspacePathError if the path doesn't exist, isn't a directory, or contains no immediate subdirectory with a state.vscdb marker (the same heuristic /api/validate-path already uses). - Returns the canonical real path so the override is stored in canonical form, not whatever the caller sent. api/config_api.py:set_workspace now calls the validator and returns 400 { error: "<reason>" } on rejection (was silently 200), and stores the canonical path on success: 200 { success: true, path: "<canonical>" }. Lives outside api/ so the test suite can import without Flask in scope (tests/test_cli_args.py convention). Tests: tests/test_workspace_path_validation.py — 11 cases covering: - happy path with marker file present - canonical path returned (`..` collapsed) - empty / whitespace / non-string rejected - non-existent / file-not-directory rejected - directory without Cursor markers rejected - traversal lands outside workspace → rejected on markers - symlink → / rejected on markers (POSIX-only) - symlink → real workspace canonicalised + accepted (POSIX-only) Full suite: 148/148 OK (was 137; +11 new). Live HTTP smoke against the running app verified all 9 documented behaviours (200 with canonical path on accept; 400 with the documented reason on each reject).
1 parent f8b3cb3 commit 75468a2

3 files changed

Lines changed: 231 additions & 7 deletions

File tree

api/config_api.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from flask import Blueprint, jsonify, request
1414

1515
from utils.path_helpers import expand_tilde_path
16+
from utils.path_validation import WorkspacePathError, validate_workspace_path
1617
from utils.workspace_path import set_workspace_path_override
1718

1819
bp = Blueprint("config_api", __name__)
@@ -75,14 +76,21 @@ def validate_path():
7576

7677
@bp.route("/api/set-workspace", methods=["POST"])
7778
def set_workspace():
79+
body = request.get_json(silent=True) or {}
80+
raw = body.get("path", "")
81+
# Validate the supplied path BEFORE storing the override (issue #15).
82+
# validate_workspace_path collapses `..` traversal AND resolves symlinks
83+
# via realpath, then enforces that the canonical target is an existing
84+
# directory containing Cursor workspace markers. Returns the canonical
85+
# path so we store that, not whatever the caller sent.
7886
try:
79-
body = request.get_json(silent=True) or {}
80-
path = body.get("path", "")
81-
expanded = expand_tilde_path(path)
82-
set_workspace_path_override(expanded)
83-
return jsonify({"success": True})
84-
except Exception:
85-
return jsonify({"error": "Failed to set workspace path"}), 500
87+
canonical = validate_workspace_path(raw)
88+
except WorkspacePathError as e:
89+
return jsonify({"error": str(e)}), 400
90+
except Exception: # noqa: BLE001 — only here as a fallback
91+
return jsonify({"error": "Failed to validate workspace path"}), 500
92+
set_workspace_path_override(canonical)
93+
return jsonify({"success": True, "path": canonical})
8694

8795

8896
@bp.route("/api/get-username")
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
Regression tests for issue #15 — /api/set-workspace path validation.
3+
4+
Exercises validate_workspace_path() directly. Imports from utils/ to avoid
5+
pulling Flask into scope (tests/test_cli_args.py convention).
6+
7+
Run:
8+
python -m unittest tests.test_workspace_path_validation -v
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import shutil
15+
import sys
16+
import tempfile
17+
import unittest
18+
19+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20+
sys.path.insert(0, REPO_ROOT)
21+
22+
from utils.path_validation import WorkspacePathError, validate_workspace_path
23+
24+
25+
def _make_cursor_workspace_dir(parent: str, name: str = "real-storage") -> str:
26+
"""Create a directory that looks like a Cursor workspaceStorage dir.
27+
28+
Layout:
29+
<parent>/<name>/
30+
ws-001/state.vscdb ← marker file the validator looks for
31+
"""
32+
storage = os.path.join(parent, name)
33+
ws = os.path.join(storage, "ws-001")
34+
os.makedirs(ws)
35+
with open(os.path.join(ws, "state.vscdb"), "wb") as f:
36+
f.write(b"")
37+
return storage
38+
39+
40+
class TestValidateWorkspacePath(unittest.TestCase):
41+
42+
def setUp(self):
43+
self.tmp = tempfile.mkdtemp(prefix="cursor-validate-test-")
44+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
45+
46+
# ─── Happy path ────────────────────────────────────────────────
47+
48+
def test_accepts_directory_with_cursor_marker(self):
49+
storage = _make_cursor_workspace_dir(self.tmp)
50+
result = validate_workspace_path(storage)
51+
self.assertEqual(result, os.path.realpath(storage))
52+
53+
def test_returns_canonical_path_collapsing_dotdot(self):
54+
# /tmp/<x>/real-storage/../real-storage → /tmp/<x>/real-storage
55+
storage = _make_cursor_workspace_dir(self.tmp)
56+
traversal_input = os.path.join(storage, "..", os.path.basename(storage))
57+
result = validate_workspace_path(traversal_input)
58+
self.assertEqual(result, os.path.realpath(storage))
59+
self.assertNotIn("..", result)
60+
61+
# ─── Hard rejects ──────────────────────────────────────────────
62+
63+
def test_rejects_empty_string(self):
64+
with self.assertRaises(WorkspacePathError) as ctx:
65+
validate_workspace_path("")
66+
self.assertIn("required", str(ctx.exception))
67+
68+
def test_rejects_whitespace_only(self):
69+
with self.assertRaises(WorkspacePathError):
70+
validate_workspace_path(" \t ")
71+
72+
def test_rejects_non_string(self):
73+
with self.assertRaises(WorkspacePathError):
74+
validate_workspace_path(None) # type: ignore[arg-type]
75+
76+
def test_rejects_non_existent_path(self):
77+
bogus = os.path.join(self.tmp, "does-not-exist", "anywhere")
78+
with self.assertRaises(WorkspacePathError) as ctx:
79+
validate_workspace_path(bogus)
80+
self.assertIn("does not exist", str(ctx.exception))
81+
82+
def test_rejects_file_not_directory(self):
83+
f = os.path.join(self.tmp, "regular-file")
84+
with open(f, "w") as h:
85+
h.write("not a directory")
86+
with self.assertRaises(WorkspacePathError) as ctx:
87+
validate_workspace_path(f)
88+
self.assertIn("not a directory", str(ctx.exception))
89+
90+
def test_rejects_directory_without_cursor_markers(self):
91+
# Existing directory but no state.vscdb anywhere — common case for
92+
# a user pointing at /tmp, /etc, /, ~/.ssh, etc.
93+
plain = os.path.join(self.tmp, "plain-dir")
94+
os.makedirs(os.path.join(plain, "subdir"))
95+
with self.assertRaises(WorkspacePathError) as ctx:
96+
validate_workspace_path(plain)
97+
self.assertIn("Cursor workspaceStorage", str(ctx.exception))
98+
99+
# ─── Path-traversal class ──────────────────────────────────────
100+
101+
def test_traversal_into_non_workspace_is_rejected(self):
102+
# /tmp/<x>/real-storage/../../ → /tmp/ → no Cursor markers → reject
103+
storage = _make_cursor_workspace_dir(self.tmp)
104+
escape = os.path.join(storage, "..", "..")
105+
with self.assertRaises(WorkspacePathError):
106+
validate_workspace_path(escape)
107+
108+
# ─── Symlink-escape class ──────────────────────────────────────
109+
110+
@unittest.skipIf(sys.platform == "win32", "POSIX symlinks only")
111+
def test_symlink_to_non_workspace_is_rejected(self):
112+
# A symlink that points to / (no Cursor markers) is rejected because
113+
# realpath() resolves to the real target before the marker check.
114+
link = os.path.join(self.tmp, "evil-link")
115+
os.symlink("/", link)
116+
with self.assertRaises(WorkspacePathError) as ctx:
117+
validate_workspace_path(link)
118+
self.assertIn("Cursor workspaceStorage", str(ctx.exception))
119+
120+
@unittest.skipIf(sys.platform == "win32", "POSIX symlinks only")
121+
def test_symlink_to_real_workspace_is_canonicalised_and_accepted(self):
122+
# Symlink → real Cursor storage. Accepted, but the canonical path
123+
# returned is the realpath (the storage dir), NOT the symlink path.
124+
storage = _make_cursor_workspace_dir(self.tmp)
125+
link = os.path.join(self.tmp, "good-link")
126+
os.symlink(storage, link)
127+
result = validate_workspace_path(link)
128+
self.assertEqual(result, os.path.realpath(storage))
129+
self.assertNotEqual(result, link)
130+
131+
132+
if __name__ == "__main__":
133+
unittest.main()

utils/path_validation.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Validation for workspace paths submitted via /api/set-workspace.
2+
3+
Lives outside ``api/`` so the unit tests can import it without pulling
4+
Flask into scope (the existing test suite intentionally avoids Flask —
5+
see ``tests/test_cli_args.py`` for the convention).
6+
7+
The validation collapses path traversal *and* resolves symlinks via
8+
``os.path.realpath()`` in a single step. Both ``/foo/../bar`` and a
9+
symlink that points outside the intended tree become whatever the
10+
canonical real path is on disk; downstream checks then operate on
11+
that canonical value, not on whatever the caller sent.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import os
17+
18+
from .path_helpers import expand_tilde_path
19+
20+
21+
class WorkspacePathError(ValueError):
22+
"""Raised when a /api/set-workspace path fails validation.
23+
24+
Carries a single ``reason`` string suitable for a 400 response body.
25+
Distinct exception type so the API handler can map it to a 400 while
26+
letting unexpected exceptions surface as 500.
27+
"""
28+
29+
30+
def _has_cursor_workspace_markers(directory: str) -> bool:
31+
"""Return True iff at least one immediate subdirectory contains state.vscdb.
32+
33+
Same heuristic /api/validate-path already uses to recognise a Cursor
34+
workspaceStorage directory. Used here as the final accept gate so that
35+
a symlink whose realpath happens to leave the user's own data area
36+
(e.g. /tmp, /etc) is rejected — those locations have no state.vscdb.
37+
"""
38+
try:
39+
names = os.listdir(directory)
40+
except OSError:
41+
return False
42+
for name in names:
43+
full = os.path.join(directory, name)
44+
try:
45+
if os.path.isdir(full) and os.path.isfile(os.path.join(full, "state.vscdb")):
46+
return True
47+
except OSError:
48+
continue
49+
return False
50+
51+
52+
def validate_workspace_path(raw_path: str) -> str:
53+
"""Validate a /api/set-workspace input and return the canonical real path.
54+
55+
Raises :class:`WorkspacePathError` if the path:
56+
- is empty / not a string,
57+
- does not exist after symlink + ``..`` resolution,
58+
- is not a directory,
59+
- contains no Cursor workspace markers (no immediate subdir with state.vscdb).
60+
61+
On success, returns the canonical absolute real path. The caller should
62+
store that, not the raw input, so subsequent reads resolve through the
63+
same canonical value.
64+
"""
65+
if not isinstance(raw_path, str) or not raw_path.strip():
66+
raise WorkspacePathError("path is required")
67+
68+
expanded = expand_tilde_path(raw_path)
69+
# realpath() collapses `..` AND resolves symlinks. Both classes of escape
70+
# become equivalent to whatever is actually on disk.
71+
real = os.path.realpath(expanded)
72+
73+
if not os.path.exists(real):
74+
raise WorkspacePathError("path does not exist")
75+
if not os.path.isdir(real):
76+
raise WorkspacePathError("path is not a directory")
77+
if not _has_cursor_workspace_markers(real):
78+
raise WorkspacePathError(
79+
"path does not look like a Cursor workspaceStorage directory "
80+
"(no immediate subdirectory contains state.vscdb)"
81+
)
82+
83+
return real

0 commit comments

Comments
 (0)