Problem
POST /api/set-workspace (api/config_api.py:76-85) accepts any string in body.path, runs expand_tilde_path() for ~/ expansion, and stores the result in a module-global override consumed by resolve_workspace_path() for every subsequent file lookup. There is no validation that the path:
- exists,
- is a directory,
- is not a symlink to somewhere unexpected,
- doesn't traverse outside an allowed root via
..,
- looks anything like a Cursor workspace storage directory.
A client (anyone who can reach the dashboard, including a hostile JS payload — see related XSS issue #11) can post { "path": "/etc" } or { "path": "/home/user/.ssh" } and the app will then serve those files as if they were Cursor chat data. Symlink-based escape (e.g. /tmp/cursor-link → /) bypasses naive startswith-style checks that don't resolve symlinks.
/api/validate-path (api/config_api.py:51-73) has some sanity (looks for state.vscdb in subdirs) but /api/set-workspace does not call it before accepting the path.
Suggested fix
Add a single validate_workspace_path(raw) helper in utils/ (testable without Flask) that:
- Expands
~/.
- Calls
os.path.realpath() to collapse traversal AND resolve symlinks in one shot. .. segments and symlink escapes both become equivalent to the canonical real path.
- Rejects with a structured error if the path doesn't exist, isn't a directory, or contains no Cursor workspace markers (
state.vscdb under any immediate subdirectory — 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.
POST /api/set-workspace calls the helper and returns:
200 { success: true, path: "<canonical>" } on accept.
400 { error: "<reason>" } on any rejection (don't silently 200).
Regression tests exercise the helper directly:
.. traversal collapsed (/x/y/../z → /x/z).
- Symlink escape rejected (a symlink whose realpath leaves the staging dir is rejected on the no-Cursor-marker check).
- Non-existent path rejected.
- File (not directory) rejected.
- Directory without Cursor markers rejected.
- Valid Cursor workspace storage accepted; canonical path returned.
Severity
Critical — Listed as a Critical / 5pt item in Will's eval week-1 plan for cppa-cursor-browser. Combined with the XSS issue #11, an attacker who lands a single payload in any chat owns the local filesystem read surface of the dashboard origin.
Problem
POST /api/set-workspace(api/config_api.py:76-85) accepts any string inbody.path, runsexpand_tilde_path()for~/expansion, and stores the result in a module-global override consumed byresolve_workspace_path()for every subsequent file lookup. There is no validation that the path:..,A client (anyone who can reach the dashboard, including a hostile JS payload — see related XSS issue #11) can post
{ "path": "/etc" }or{ "path": "/home/user/.ssh" }and the app will then serve those files as if they were Cursor chat data. Symlink-based escape (e.g./tmp/cursor-link → /) bypasses naivestartswith-style checks that don't resolve symlinks./api/validate-path(api/config_api.py:51-73) has some sanity (looks forstate.vscdbin subdirs) but/api/set-workspacedoes not call it before accepting the path.Suggested fix
Add a single
validate_workspace_path(raw)helper inutils/(testable without Flask) that:~/.os.path.realpath()to collapse traversal AND resolve symlinks in one shot...segments and symlink escapes both become equivalent to the canonical real path.state.vscdbunder any immediate subdirectory — same heuristic/api/validate-pathalready uses).POST /api/set-workspacecalls the helper and returns:200 { success: true, path: "<canonical>" }on accept.400 { error: "<reason>" }on any rejection (don't silently 200).Regression tests exercise the helper directly:
..traversal collapsed (/x/y/../z→/x/z).Severity
Critical — Listed as a Critical / 5pt item in Will's eval week-1 plan for
cppa-cursor-browser. Combined with the XSS issue #11, an attacker who lands a single payload in any chat owns the local filesystem read surface of the dashboard origin.