From 75468a20ee1674d86a3f5878ba8073f92c66a042 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Tue, 5 May 2026 16:33:35 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20validate=20/api/set-workspace=20path?= =?UTF-8?q?=20=E2=80=94=20reject=20traversal,=20symlink=20escape,=20non-ex?= =?UTF-8?q?istent=20(closes=20#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: "" } on rejection (was silently 200), and stores the canonical path on success: 200 { success: true, path: "" }. 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). --- api/config_api.py | 22 ++-- tests/test_workspace_path_validation.py | 133 ++++++++++++++++++++++++ utils/path_validation.py | 83 +++++++++++++++ 3 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 tests/test_workspace_path_validation.py create mode 100644 utils/path_validation.py diff --git a/api/config_api.py b/api/config_api.py index 32dc21e..40bf553 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, request from utils.path_helpers import expand_tilde_path +from utils.path_validation import WorkspacePathError, validate_workspace_path from utils.workspace_path import set_workspace_path_override bp = Blueprint("config_api", __name__) @@ -75,14 +76,21 @@ def validate_path(): @bp.route("/api/set-workspace", methods=["POST"]) def set_workspace(): + body = request.get_json(silent=True) or {} + raw = body.get("path", "") + # Validate the supplied path BEFORE storing the override (issue #15). + # validate_workspace_path collapses `..` traversal AND resolves symlinks + # via realpath, then enforces that the canonical target is an existing + # directory containing Cursor workspace markers. Returns the canonical + # path so we store that, not whatever the caller sent. try: - body = request.get_json(silent=True) or {} - path = body.get("path", "") - expanded = expand_tilde_path(path) - set_workspace_path_override(expanded) - return jsonify({"success": True}) - except Exception: - return jsonify({"error": "Failed to set workspace path"}), 500 + canonical = validate_workspace_path(raw) + except WorkspacePathError as e: + return jsonify({"error": str(e)}), 400 + except Exception: # noqa: BLE001 — only here as a fallback + return jsonify({"error": "Failed to validate workspace path"}), 500 + set_workspace_path_override(canonical) + return jsonify({"success": True, "path": canonical}) @bp.route("/api/get-username") diff --git a/tests/test_workspace_path_validation.py b/tests/test_workspace_path_validation.py new file mode 100644 index 0000000..c23bc61 --- /dev/null +++ b/tests/test_workspace_path_validation.py @@ -0,0 +1,133 @@ +""" +Regression tests for issue #15 — /api/set-workspace path validation. + +Exercises validate_workspace_path() directly. Imports from utils/ to avoid +pulling Flask into scope (tests/test_cli_args.py convention). + +Run: + python -m unittest tests.test_workspace_path_validation -v +""" + +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +from utils.path_validation import WorkspacePathError, validate_workspace_path + + +def _make_cursor_workspace_dir(parent: str, name: str = "real-storage") -> str: + """Create a directory that looks like a Cursor workspaceStorage dir. + + Layout: + // + ws-001/state.vscdb ← marker file the validator looks for + """ + storage = os.path.join(parent, name) + ws = os.path.join(storage, "ws-001") + os.makedirs(ws) + with open(os.path.join(ws, "state.vscdb"), "wb") as f: + f.write(b"") + return storage + + +class TestValidateWorkspacePath(unittest.TestCase): + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="cursor-validate-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + + # ─── Happy path ──────────────────────────────────────────────── + + def test_accepts_directory_with_cursor_marker(self): + storage = _make_cursor_workspace_dir(self.tmp) + result = validate_workspace_path(storage) + self.assertEqual(result, os.path.realpath(storage)) + + def test_returns_canonical_path_collapsing_dotdot(self): + # /tmp//real-storage/../real-storage → /tmp//real-storage + storage = _make_cursor_workspace_dir(self.tmp) + traversal_input = os.path.join(storage, "..", os.path.basename(storage)) + result = validate_workspace_path(traversal_input) + self.assertEqual(result, os.path.realpath(storage)) + self.assertNotIn("..", result) + + # ─── Hard rejects ────────────────────────────────────────────── + + def test_rejects_empty_string(self): + with self.assertRaises(WorkspacePathError) as ctx: + validate_workspace_path("") + self.assertIn("required", str(ctx.exception)) + + def test_rejects_whitespace_only(self): + with self.assertRaises(WorkspacePathError): + validate_workspace_path(" \t ") + + def test_rejects_non_string(self): + with self.assertRaises(WorkspacePathError): + validate_workspace_path(None) # type: ignore[arg-type] + + def test_rejects_non_existent_path(self): + bogus = os.path.join(self.tmp, "does-not-exist", "anywhere") + with self.assertRaises(WorkspacePathError) as ctx: + validate_workspace_path(bogus) + self.assertIn("does not exist", str(ctx.exception)) + + def test_rejects_file_not_directory(self): + f = os.path.join(self.tmp, "regular-file") + with open(f, "w") as h: + h.write("not a directory") + with self.assertRaises(WorkspacePathError) as ctx: + validate_workspace_path(f) + self.assertIn("not a directory", str(ctx.exception)) + + def test_rejects_directory_without_cursor_markers(self): + # Existing directory but no state.vscdb anywhere — common case for + # a user pointing at /tmp, /etc, /, ~/.ssh, etc. + plain = os.path.join(self.tmp, "plain-dir") + os.makedirs(os.path.join(plain, "subdir")) + with self.assertRaises(WorkspacePathError) as ctx: + validate_workspace_path(plain) + self.assertIn("Cursor workspaceStorage", str(ctx.exception)) + + # ─── Path-traversal class ────────────────────────────────────── + + def test_traversal_into_non_workspace_is_rejected(self): + # /tmp//real-storage/../../ → /tmp/ → no Cursor markers → reject + storage = _make_cursor_workspace_dir(self.tmp) + escape = os.path.join(storage, "..", "..") + with self.assertRaises(WorkspacePathError): + validate_workspace_path(escape) + + # ─── Symlink-escape class ────────────────────────────────────── + + @unittest.skipIf(sys.platform == "win32", "POSIX symlinks only") + def test_symlink_to_non_workspace_is_rejected(self): + # A symlink that points to / (no Cursor markers) is rejected because + # realpath() resolves to the real target before the marker check. + link = os.path.join(self.tmp, "evil-link") + os.symlink("/", link) + with self.assertRaises(WorkspacePathError) as ctx: + validate_workspace_path(link) + self.assertIn("Cursor workspaceStorage", str(ctx.exception)) + + @unittest.skipIf(sys.platform == "win32", "POSIX symlinks only") + def test_symlink_to_real_workspace_is_canonicalised_and_accepted(self): + # Symlink → real Cursor storage. Accepted, but the canonical path + # returned is the realpath (the storage dir), NOT the symlink path. + storage = _make_cursor_workspace_dir(self.tmp) + link = os.path.join(self.tmp, "good-link") + os.symlink(storage, link) + result = validate_workspace_path(link) + self.assertEqual(result, os.path.realpath(storage)) + self.assertNotEqual(result, link) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/path_validation.py b/utils/path_validation.py new file mode 100644 index 0000000..e919eb2 --- /dev/null +++ b/utils/path_validation.py @@ -0,0 +1,83 @@ +"""Validation for workspace paths submitted via /api/set-workspace. + +Lives outside ``api/`` so the unit tests can import it without pulling +Flask into scope (the existing test suite intentionally avoids Flask — +see ``tests/test_cli_args.py`` for the convention). + +The validation collapses path traversal *and* resolves symlinks via +``os.path.realpath()`` in a single step. Both ``/foo/../bar`` and a +symlink that points outside the intended tree become whatever the +canonical real path is on disk; downstream checks then operate on +that canonical value, not on whatever the caller sent. +""" + +from __future__ import annotations + +import os + +from .path_helpers import expand_tilde_path + + +class WorkspacePathError(ValueError): + """Raised when a /api/set-workspace path fails validation. + + Carries a single ``reason`` string suitable for a 400 response body. + Distinct exception type so the API handler can map it to a 400 while + letting unexpected exceptions surface as 500. + """ + + +def _has_cursor_workspace_markers(directory: str) -> bool: + """Return True iff at least one immediate subdirectory contains state.vscdb. + + Same heuristic /api/validate-path already uses to recognise a Cursor + workspaceStorage directory. Used here as the final accept gate so that + a symlink whose realpath happens to leave the user's own data area + (e.g. /tmp, /etc) is rejected — those locations have no state.vscdb. + """ + try: + names = os.listdir(directory) + except OSError: + return False + for name in names: + full = os.path.join(directory, name) + try: + if os.path.isdir(full) and os.path.isfile(os.path.join(full, "state.vscdb")): + return True + except OSError: + continue + return False + + +def validate_workspace_path(raw_path: str) -> str: + """Validate a /api/set-workspace input and return the canonical real path. + + Raises :class:`WorkspacePathError` if the path: + - is empty / not a string, + - does not exist after symlink + ``..`` resolution, + - is not a directory, + - contains no Cursor workspace markers (no immediate subdir with state.vscdb). + + On success, returns the canonical absolute real path. The caller should + store that, not the raw input, so subsequent reads resolve through the + same canonical value. + """ + if not isinstance(raw_path, str) or not raw_path.strip(): + raise WorkspacePathError("path is required") + + expanded = expand_tilde_path(raw_path) + # realpath() collapses `..` AND resolves symlinks. Both classes of escape + # become equivalent to whatever is actually on disk. + real = os.path.realpath(expanded) + + if not os.path.exists(real): + raise WorkspacePathError("path does not exist") + if not os.path.isdir(real): + raise WorkspacePathError("path is not a directory") + if not _has_cursor_workspace_markers(real): + raise WorkspacePathError( + "path does not look like a Cursor workspaceStorage directory " + "(no immediate subdirectory contains state.vscdb)" + ) + + return real From a63297f4c7a622cddd1d8b1e17081bb76e2d7864 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Tue, 5 May 2026 17:01:17 +0200 Subject: [PATCH 2/6] fix: harden /api/set-workspace handler + traversal test (CodeRabbit on PR #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit follow-ups: 1. api/config_api.py:set_workspace — when get_json(silent=True) returned a non-dict (JSON array, string, number, null), the truthy fallback `or {}` was bypassed and `body.get("path", "")` raised AttributeError, which the outer Exception handler mis-reported as a 500 server error. Added isinstance(body, dict) guard that returns 400 { error: "request body must be a JSON object" } directly. Diverged from CodeRabbit's literal patch in one way: they had it raise WorkspacePathError("path is required"), but the actual problem here is a malformed body shape — the error message should match the cause so the client can fix it. 2. tests/test_workspace_path_validation.py — the traversal test escaped to /tmp itself, which is shared and could be flipped by any other test / process creating /state.vscdb there. Pinned the escape target to an isolated root inside self.tmp. Also added 4 API-layer regression tests (TestSetWorkspaceApi) using Flask test_client: JSON array / string / number return 400 (not 500), plus a sanity end-to-end with a valid {path} body returning 200 and the canonical realpath. Full suite: 152/152 OK (was 148; +4 new API tests). --- api/config_api.py | 9 +++- tests/test_workspace_path_validation.py | 72 ++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/api/config_api.py b/api/config_api.py index 40bf553..a7381ee 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -76,7 +76,14 @@ def validate_path(): @bp.route("/api/set-workspace", methods=["POST"]) def set_workspace(): - body = request.get_json(silent=True) or {} + # Reject non-dict JSON bodies (array / string / number / null). Without + # this, get_json returns the value directly, the truthy fallback `or {}` + # is bypassed, and `body.get("path", "")` raises AttributeError — which + # the outer Exception handler then mis-reports as a 500 server error + # instead of a 400 client error. (CodeRabbit on PR #16.) + body = request.get_json(silent=True) + if not isinstance(body, dict): + return jsonify({"error": "request body must be a JSON object"}), 400 raw = body.get("path", "") # Validate the supplied path BEFORE storing the override (issue #15). # validate_workspace_path collapses `..` traversal AND resolves symlinks diff --git a/tests/test_workspace_path_validation.py b/tests/test_workspace_path_validation.py index c23bc61..8b3da9b 100644 --- a/tests/test_workspace_path_validation.py +++ b/tests/test_workspace_path_validation.py @@ -99,8 +99,16 @@ def test_rejects_directory_without_cursor_markers(self): # ─── Path-traversal class ────────────────────────────────────── def test_traversal_into_non_workspace_is_rejected(self): - # /tmp//real-storage/../../ → /tmp/ → no Cursor markers → reject - storage = _make_cursor_workspace_dir(self.tmp) + # Keep traversal target inside this test's own temp tree — escaping + # to /tmp itself would be non-deterministic (any other test or + # process creating a `state.vscdb` under /tmp//state.vscdb + # would flip this test's outcome). + # + # /isolated-root/storage/../.. → /isolated-root + # which contains no state.vscdb under any subdir → reject on markers. + isolated_root = os.path.join(self.tmp, "isolated-root") + os.makedirs(isolated_root) + storage = _make_cursor_workspace_dir(isolated_root) escape = os.path.join(storage, "..", "..") with self.assertRaises(WorkspacePathError): validate_workspace_path(escape) @@ -129,5 +137,65 @@ def test_symlink_to_real_workspace_is_canonicalised_and_accepted(self): self.assertNotEqual(result, link) +class TestSetWorkspaceApi(unittest.TestCase): + """API-layer regressions for POST /api/set-workspace. + + The validator helper has its own coverage above; these cases exist to + pin behaviour the API handler owns (request body shape handling, + HTTP status mapping). Notably the non-dict-body case which used to + surface as a 500 instead of a 400 — see CodeRabbit on PR #16. + """ + + def setUp(self): + from flask import Flask + from api.config_api import bp as config_bp + + self.tmp = tempfile.mkdtemp(prefix="cursor-validate-api-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(config_bp) + self.client = app.test_client() + + def test_non_dict_json_array_returns_400_not_500(self): + # Regression: a JSON array body (truthy, non-dict) used to trip + # AttributeError on body.get(...) and surface as a 500. + resp = self.client.post( + "/api/set-workspace", + data="[]", + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) + self.assertIn("error", resp.get_json()) + + def test_non_dict_json_string_returns_400(self): + resp = self.client.post( + "/api/set-workspace", + data='"some string"', + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) + + def test_non_dict_json_number_returns_400(self): + resp = self.client.post( + "/api/set-workspace", + data="42", + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) + + def test_dict_with_valid_path_returns_200_with_canonical(self): + storage = _make_cursor_workspace_dir(self.tmp) + resp = self.client.post( + "/api/set-workspace", + json={"path": storage}, + ) + self.assertEqual(resp.status_code, 200) + body = resp.get_json() + self.assertTrue(body["success"]) + self.assertEqual(body["path"], os.path.realpath(storage)) + + if __name__ == "__main__": unittest.main() From a3b89bf4e59b376760616d098d78c77a6aa79e38 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Fri, 8 May 2026 00:07:43 +0200 Subject: [PATCH 3/6] fix(set-workspace): wrap override persistence to keep 500 as JSON (CodeRabbit on PR #22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_workspace_path() failures were already returning structured JSON, but set_workspace_path_override(canonical) was outside the try block — a persistence failure would have surfaced as Flask's HTML 500 page instead of {"error": "..."}. Wraps the call in its own try/except so the response shape stays structured for any consumer parsing JSON. --- api/config_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/config_api.py b/api/config_api.py index a7381ee..18fc9bd 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -96,7 +96,10 @@ def set_workspace(): return jsonify({"error": str(e)}), 400 except Exception: # noqa: BLE001 — only here as a fallback return jsonify({"error": "Failed to validate workspace path"}), 500 - set_workspace_path_override(canonical) + try: + set_workspace_path_override(canonical) + except Exception: # noqa: BLE001 — keep the response shape structured JSON + return jsonify({"error": "Failed to set workspace path"}), 500 return jsonify({"success": True, "path": canonical}) From b7ba2201efb1c8ebc506cb19744ba9d0303b13e8 Mon Sep 17 00:00:00 2001 From: yu-med Date: Sat, 9 May 2026 04:04:03 +0800 Subject: [PATCH 4/6] test(set-workspace): segment-aware `..` assert + reset override after API tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test-hygiene follow-ups from a structured re-review of PR #22; no production code changes. 1. tests/test_workspace_path_validation.py — test_returns_canonical_path_collapsing_dotdot The canonical-path assertion was a substring check against `..` on the raw realpath string. That would spuriously fail if the OS-supplied tempdir name ever embedded `..` in a folder component — substring presence is the wrong primitive for the invariant we actually care about, which is "no `..` *segment* in the canonical path." Switched to `Path(result).parts`, which handles `\` vs `/` natively and asserts on path components. 2. tests/test_workspace_path_validation.py — TestSetWorkspaceApi.setUp The 200-path test mutates the module-global _workspace_path_override in utils/workspace_path.py via the API, and the tempdir it then points at is rmtree'd by the existing cleanup. Without an explicit reset, the global stays pinned at a now-deleted path across tests. Added addCleanup(set_workspace_path_override, None) so any future sibling test inspecting the override sees a clean None. Full suite: 152/152 OK (skipped=2 POSIX-only symlink tests on Windows). No behaviour change; ReadLints clean. Co-authored-by: Cursor --- tests/test_workspace_path_validation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_workspace_path_validation.py b/tests/test_workspace_path_validation.py index 8b3da9b..8db92c2 100644 --- a/tests/test_workspace_path_validation.py +++ b/tests/test_workspace_path_validation.py @@ -15,6 +15,7 @@ import sys import tempfile import unittest +from pathlib import Path REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, REPO_ROOT) @@ -56,7 +57,10 @@ def test_returns_canonical_path_collapsing_dotdot(self): traversal_input = os.path.join(storage, "..", os.path.basename(storage)) result = validate_workspace_path(traversal_input) self.assertEqual(result, os.path.realpath(storage)) - self.assertNotIn("..", result) + # Assert no `..` *segment* in the canonical path (vs. a substring check + # on the raw string, which would spuriously fail if the OS-supplied + # tempdir name ever embedded `..` in a folder name). + self.assertNotIn(os.pardir, Path(result).parts) # ─── Hard rejects ────────────────────────────────────────────── @@ -149,9 +153,15 @@ class TestSetWorkspaceApi(unittest.TestCase): def setUp(self): from flask import Flask from api.config_api import bp as config_bp + from utils.workspace_path import set_workspace_path_override self.tmp = tempfile.mkdtemp(prefix="cursor-validate-api-test-") self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + # Reset the module-global workspace override after each test. The + # 200-path test below mutates it via the API and the tempdir is then + # rmtree'd by the cleanup above — without this, a future sibling test + # inspecting the override would see a stale, now-deleted path. + self.addCleanup(set_workspace_path_override, None) app = Flask(__name__) app.config["TESTING"] = True From 8284b58cc7f8fec27246760b6e4702df969f5bd3 Mon Sep 17 00:00:00 2001 From: Monkey Dev Date: Fri, 8 May 2026 16:07:03 -0400 Subject: [PATCH 5/6] Align /api/validate-path with validate_workspace_path (PR #22) - POST /api/validate-path now uses the same realpath + marker checks as set-workspace; returns canonical path and structured errors on failure. - README documents WORKSPACE_PATH as trusted-operator tilde expansion only. - Config page shows server error text when validation fails. - Docstrings + symlink-test CI note; TOCTOU comment after realpath. Co-authored-by: Cursor --- README.md | 2 ++ api/config_api.py | 24 +++++++++++++++--------- templates/config.html | 2 +- tests/test_workspace_path_validation.py | 19 +++++++++++++++++++ utils/path_validation.py | 15 ++++++++++----- utils/workspace_path.py | 7 ++++++- 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 53ed2b1..18ef851 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ The application automatically detects your Cursor workspace storage location: To override, set the `WORKSPACE_PATH` environment variable or use the Configuration page in the web UI. +Paths submitted through **`POST /api/set-workspace`** (and **`POST /api/validate-path`**) are validated the same way: canonical resolution (`realpath`), directory checks, and Cursor workspace markers (`state.vscdb` under immediate subdirectories). The **`WORKSPACE_PATH`** environment variable is only tilde-expanded — it is a **trusted-operator** escape hatch for automation and known-good paths, not a substitute for those API checks when untrusted input matters. + Cursor CLI agent sessions are read from `~/.cursor/chats/` (the default path used by the `cursor agent` CLI). Override with the `CLI_CHATS_PATH` environment variable. ## Project Structure diff --git a/api/config_api.py b/api/config_api.py index 18fc9bd..93772cd 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -12,7 +12,6 @@ from flask import Blueprint, jsonify, request -from utils.path_helpers import expand_tilde_path from utils.path_validation import WorkspacePathError, validate_workspace_path from utils.workspace_path import set_workspace_path_override @@ -51,23 +50,30 @@ def detect_environment(): @bp.route("/api/validate-path", methods=["POST"]) def validate_path(): + """Same path rules as POST /api/set-workspace: realpath, markers (issue #15).""" try: body = request.get_json(silent=True) or {} - workspace_path = body.get("path", "") - expanded = expand_tilde_path(workspace_path) - - if not os.path.isdir(expanded): - return jsonify({"valid": False, "error": "Path does not exist"}) + raw = body.get("path", "") + try: + canonical = validate_workspace_path(raw) + except WorkspacePathError as e: + return jsonify({"valid": False, "error": str(e), "workspaceCount": 0}) workspace_count = 0 - for name in os.listdir(expanded): - full = os.path.join(expanded, name) + for name in os.listdir(canonical): + full = os.path.join(canonical, name) if os.path.isdir(full): db = os.path.join(full, "state.vscdb") if os.path.isfile(db): workspace_count += 1 - return jsonify({"valid": workspace_count > 0, "workspaceCount": workspace_count}) + return jsonify( + { + "valid": workspace_count > 0, + "workspaceCount": workspace_count, + "path": canonical, + } + ) except Exception as e: print(f"Validation error: {e}") diff --git a/templates/config.html b/templates/config.html index 63a2a10..2b69c45 100644 --- a/templates/config.html +++ b/templates/config.html @@ -96,7 +96,7 @@

Configuration

setTimeout(() => { window.location.href = '/'; }, 1000); } else { statusEl.className = 'alert alert-danger'; - statusEl.textContent = 'No workspaces found in the specified location'; + statusEl.textContent = data.error || 'No workspaces found in the specified location'; statusEl.style.display = 'block'; } } catch (e) { diff --git a/tests/test_workspace_path_validation.py b/tests/test_workspace_path_validation.py index 8b3da9b..d267f8e 100644 --- a/tests/test_workspace_path_validation.py +++ b/tests/test_workspace_path_validation.py @@ -114,6 +114,7 @@ def test_traversal_into_non_workspace_is_rejected(self): validate_workspace_path(escape) # ─── Symlink-escape class ────────────────────────────────────── + # POSIX-only; CI runs tests on ubuntu-latest so these still run in CI. @unittest.skipIf(sys.platform == "win32", "POSIX symlinks only") def test_symlink_to_non_workspace_is_rejected(self): @@ -196,6 +197,24 @@ def test_dict_with_valid_path_returns_200_with_canonical(self): self.assertTrue(body["success"]) self.assertEqual(body["path"], os.path.realpath(storage)) + def test_validate_path_returns_canonical_and_count(self): + storage = _make_cursor_workspace_dir(self.tmp) + resp = self.client.post("/api/validate-path", json={"path": storage}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data["valid"]) + self.assertGreaterEqual(data["workspaceCount"], 1) + self.assertEqual(data["path"], os.path.realpath(storage)) + + def test_validate_path_invalid_returns_error(self): + plain = os.path.join(self.tmp, "no-markers") + os.makedirs(plain) + resp = self.client.post("/api/validate-path", json={"path": plain}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertFalse(data["valid"]) + self.assertIn("error", data) + if __name__ == "__main__": unittest.main() diff --git a/utils/path_validation.py b/utils/path_validation.py index e919eb2..9f94178 100644 --- a/utils/path_validation.py +++ b/utils/path_validation.py @@ -1,4 +1,4 @@ -"""Validation for workspace paths submitted via /api/set-workspace. +"""Validation for workspace paths submitted via /api/set-workspace and /api/validate-path. Lives outside ``api/`` so the unit tests can import it without pulling Flask into scope (the existing test suite intentionally avoids Flask — @@ -30,9 +30,10 @@ class WorkspacePathError(ValueError): def _has_cursor_workspace_markers(directory: str) -> bool: """Return True iff at least one immediate subdirectory contains state.vscdb. - Same heuristic /api/validate-path already uses to recognise a Cursor - workspaceStorage directory. Used here as the final accept gate so that - a symlink whose realpath happens to leave the user's own data area + Same heuristic as POST /api/validate-path (counts workspaces). Nested + layouts beyond one level are out of scope per issue #15. Used here as the + final accept gate so that a symlink whose realpath happens to leave the + user's own data area (e.g. /tmp, /etc) is rejected — those locations have no state.vscdb. """ try: @@ -50,7 +51,9 @@ def _has_cursor_workspace_markers(directory: str) -> bool: def validate_workspace_path(raw_path: str) -> str: - """Validate a /api/set-workspace input and return the canonical real path. + """Validate a workspace path input and return the canonical real path. + + Used by POST /api/set-workspace and POST /api/validate-path (issue #15). Raises :class:`WorkspacePathError` if the path: - is empty / not a string, @@ -69,6 +72,8 @@ def validate_workspace_path(raw_path: str) -> str: # realpath() collapses `..` AND resolves symlinks. Both classes of escape # become equivalent to whatever is actually on disk. real = os.path.realpath(expanded) + # Classic TOCTOU: the tree could change before listdir below; low practical + # risk for this single-user local tool (issue #15 review). if not os.path.exists(real): raise WorkspacePathError("path does not exist") diff --git a/utils/workspace_path.py b/utils/workspace_path.py index 16d258b..ed4d147 100644 --- a/utils/workspace_path.py +++ b/utils/workspace_path.py @@ -58,7 +58,12 @@ def get_default_workspace_path() -> str: def resolve_workspace_path() -> str: - """Return the effective workspace path (override > env var > default).""" + """Return the effective workspace path (override > env var > default). + + Override comes from POST /api/set-workspace (validated). ``WORKSPACE_PATH`` + is only tilde-expanded — trusted-operator escape hatch, not the same checks + as the API (issue #15). + """ if _workspace_path_override: return expand_tilde_path(_workspace_path_override) env_path = os.environ.get("WORKSPACE_PATH", "").strip() From f3ac9b9f6ae5f00a7c81a6b37f5c07b403baaed1 Mon Sep 17 00:00:00 2001 From: Monkey Dev Date: Fri, 8 May 2026 16:13:56 -0400 Subject: [PATCH 6/6] validate-path: reject non-object JSON before body.get Avoid AttributeError on truthy JSON scalars/arrays (same class as PR #16). Return valid:false + workspaceCount:0 to match validation error shape. Co-authored-by: Cursor --- api/config_api.py | 4 ++++ tests/test_workspace_path_validation.py | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/api/config_api.py b/api/config_api.py index 93772cd..f5d4e47 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -53,6 +53,10 @@ def validate_path(): """Same path rules as POST /api/set-workspace: realpath, markers (issue #15).""" try: body = request.get_json(silent=True) or {} + if not isinstance(body, dict): + return jsonify( + {"valid": False, "error": "invalid JSON body", "workspaceCount": 0} + ) raw = body.get("path", "") try: canonical = validate_workspace_path(raw) diff --git a/tests/test_workspace_path_validation.py b/tests/test_workspace_path_validation.py index bb2b2ed..a1e526c 100644 --- a/tests/test_workspace_path_validation.py +++ b/tests/test_workspace_path_validation.py @@ -225,6 +225,19 @@ def test_validate_path_invalid_returns_error(self): self.assertFalse(data["valid"]) self.assertIn("error", data) + def test_validate_path_non_dict_json_returns_structured_error(self): + # Mirror set_workspace: truthy non-dict JSON must not reach body.get. + resp = self.client.post( + "/api/validate-path", + data='"not an object"', + content_type="application/json", + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertFalse(data["valid"]) + self.assertEqual(data["error"], "invalid JSON body") + self.assertEqual(data["workspaceCount"], 0) + if __name__ == "__main__": unittest.main()