Skip to content

Security: validate /api/set-workspace path (reject traversal, symlink escape, non-existent paths) #15

@timon0305

Description

@timon0305

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions