From 8caca99cae79a480c965bcb9fe0cfd4ebe6a984c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 21 Jun 2026 23:50:26 -0400 Subject: [PATCH] Preserve escape grants while locking second-hop regressions --- api/workspace.py | 2 +- ...t_issue4582_workspace_escape_navigation.py | 132 +++++++++++++++++- tests/test_routes_file_api_toctou.py | 9 ++ tests/test_workspace_symlink_containment.py | 32 +++++ 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/api/workspace.py b/api/workspace.py index 1f6bd30f..621fb8e7 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -1500,7 +1500,7 @@ def authorize_escape_target(workspace: Path, session_id: str, rel: str) -> dict: "expires_at": expires_at, } with _ESCAPE_AUTH_LOCK: - _escape_prune_tokens(expires_at) + _escape_prune_tokens() _ESCAPE_AUTH_TOKENS[token] = record return { "token": token, diff --git a/tests/test_issue4582_workspace_escape_navigation.py b/tests/test_issue4582_workspace_escape_navigation.py index d326a7b8..bf659a76 100644 --- a/tests/test_issue4582_workspace_escape_navigation.py +++ b/tests/test_issue4582_workspace_escape_navigation.py @@ -2,9 +2,14 @@ from __future__ import annotations import json import pathlib +import re +import shutil +import subprocess import urllib.error import urllib.request +import pytest + from api.routes import _project_os_workspace_read from tests._pytest_port import BASE @@ -12,6 +17,7 @@ from tests._pytest_port import BASE ROOT = pathlib.Path(__file__).resolve().parents[1] UI_JS = ROOT / "static" / "ui.js" WORKSPACE_JS = ROOT / "static" / "workspace.js" +NODE = shutil.which("node") def _get_json(path: str) -> dict: @@ -44,6 +50,31 @@ def _make_session(workspace: pathlib.Path) -> str: return payload["session"]["session_id"] +def _read_workspace_js() -> str: + return WORKSPACE_JS.read_text(encoding="utf-8") + + +def _workspace_escape_helper_block() -> str: + src = _read_workspace_js() + start = src.find("function _escapeGrantStore(){") + assert start >= 0, "escape grant helper block start not found in static/workspace.js" + end = src.find("let _workspacePanelActiveTab = 'files';", start) + assert end >= 0, "escape grant helper block end not found in static/workspace.js" + return src[start:end] + + +def _run_node(js: str) -> dict: + assert NODE is not None, "node not on PATH" + completed = subprocess.run( + [NODE, "-e", js], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + return json.loads(completed.stdout) + + class TestIssue4582EscapeNavigationLive: def test_authorized_dir_list_read_and_raw_stay_virtualized(self, tmp_path): workspace = tmp_path / "workspace" @@ -96,6 +127,7 @@ class TestIssue4582EscapeNavigationLive: second_outside.mkdir() (second_outside / "secret.txt").write_text("secret", encoding="utf-8") (outside / "nested-escape").symlink_to(second_outside) + (outside / "nested-file-escape.txt").symlink_to(second_outside / "secret.txt") (workspace / "escape").symlink_to(outside) sid = _make_session(workspace) @@ -119,13 +151,91 @@ class TestIssue4582EscapeNavigationLive: except urllib.error.HTTPError as exc: assert exc.code in (403, 404) + try: + _get_bytes( + f"/api/escape/file/raw?session_id={sid}&token={auth['token']}&path=escape/nested-file-escape.txt" + ) + assert False, "nested file escape raw read should stay blocked" + except urllib.error.HTTPError as exc: + assert exc.code in (403, 404) + + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + class TestIssue4582EscapeNavigationFrontend: - def test_workspace_route_helper_uses_escape_route_family(self): - src = WORKSPACE_JS.read_text(encoding="utf-8") - assert "/api/escape/list?" in src - assert "/api/escape/file/read?" in src - assert "/api/escape/file/raw?" in src + def test_authorization_flow_switches_routes_and_marks_subtree_read_only(self): + helper_block = _workspace_escape_helper_block() + js = ( + "const helperBlock = " + + json.dumps(helper_block) + + ";\n" + + r""" +const S = { session: { session_id: 'sess-1' }, currentDir: '.', _escapeGrants: Object.create(null) }; +const confirmCalls = []; +const apiCalls = []; +const toasts = []; +const showConfirmDialog = async (opts) => { confirmCalls.push(opts); return true; }; +const api = async (path, opts) => { + apiCalls.push({ path, method: opts && opts.method, body: opts && opts.body }); + return { + token: 'tok-123', + path: 'escape', + expires_at: 4102444800, + is_dir: true, + read_only: true, + }; +}; +const showToast = (...args) => { toasts.push(args); }; +const t = (key) => key; +const runner = new Function( + 'S', 'showConfirmDialog', 'api', 'showToast', 't', 'URLSearchParams', + helperBlock + '; return { authorizeWorkspaceEscapeNavigation, _workspaceRouteForPath, _workspacePathIsReadOnly, _workspaceEscapeGrantForPath };' +); +const apiFns = runner(S, showConfirmDialog, api, showToast, t, URLSearchParams); +(async () => { + const beforeRead = apiFns._workspaceRouteForPath('escape/note.txt', 'read'); + const beforeList = apiFns._workspaceRouteForPath('escape', 'list'); + const grant = await apiFns.authorizeWorkspaceEscapeNavigation({ path: 'escape', name: 'escape' }); + const afterRead = apiFns._workspaceRouteForPath('escape/note.txt', 'read'); + const afterRaw = apiFns._workspaceRouteForPath('escape/note.txt', 'raw', { inline: true }); + const grantLookup = apiFns._workspaceEscapeGrantForPath('escape/note.txt'); + const readOnly = apiFns._workspacePathIsReadOnly('escape/note.txt'); + console.log(JSON.stringify({ + beforeRead, + beforeList, + afterRead, + afterRaw, + grant, + grantLookup, + readOnly, + confirmCalls, + apiCalls, + toasts, + })); +})().catch((err) => { + console.error(err && err.stack ? err.stack : String(err)); + process.exit(1); +}); +""" + ) + result = _run_node(js) + assert result["beforeRead"] == "/api/file?session_id=sess-1&path=escape%2Fnote.txt" + assert result["beforeList"] == "/api/list?session_id=sess-1&path=escape" + assert result["afterRead"] == "/api/escape/file/read?session_id=sess-1&path=escape%2Fnote.txt&token=tok-123" + assert result["afterRaw"] == "/api/escape/file/raw?session_id=sess-1&path=escape%2Fnote.txt&token=tok-123&inline=1" + assert result["grant"]["path"] == "escape" + assert result["grantLookup"]["token"] == "tok-123" + assert result["readOnly"] is True + assert result["confirmCalls"][0]["message"] == "external_link_open_confirm" + assert result["apiCalls"] == [ + { + "path": "/api/escape/authorize", + "method": "POST", + "body": "{\"session_id\":\"sess-1\",\"path\":\"escape\"}", + } + ] + assert result["toasts"][0][0] == "external_link_read_only" def test_external_rows_authorize_then_open(self): src = UI_JS.read_text(encoding="utf-8") @@ -135,7 +245,17 @@ class TestIssue4582EscapeNavigationFrontend: def test_read_only_affordances_stay_suppressed(self): ui_src = UI_JS.read_text(encoding="utf-8") - ws_src = WORKSPACE_JS.read_text(encoding="utf-8") + ws_src = _read_workspace_js() assert "if(!isReadOnlyEscape){" in ui_src assert "_workspacePathIsReadOnly(_previewCurrentPath)" in ws_src assert "_workspacePathIsReadOnly(S.currentDir || '.')" in ws_src + + def test_open_in_browser_reuses_workspace_route_helper(self): + ws_src = _read_workspace_js() + match = re.search( + r"function openInBrowser\(\)\{\s*if\(!_previewCurrentPath\|\|!S\.session\) return;\s*const url=(.*?);\s*window\.open\(url,'_blank','noopener'\);\s*\}", + ws_src, + re.DOTALL, + ) + assert match, "openInBrowser helper not found" + assert "_workspaceRouteForPath(_previewCurrentPath, 'raw', {inline:true})" in match.group(1) diff --git a/tests/test_routes_file_api_toctou.py b/tests/test_routes_file_api_toctou.py index 3ce236c3..96afb74e 100644 --- a/tests/test_routes_file_api_toctou.py +++ b/tests/test_routes_file_api_toctou.py @@ -6,6 +6,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ROUTES_PY = ROOT / "api" / "routes.py" +WORKSPACE_PY = ROOT / "api" / "workspace.py" UPLOAD_PY = ROOT / "api" / "upload.py" @@ -152,6 +153,14 @@ def test_escape_raw_and_read_routes_use_authorized_helpers(): assert "_serve_file_bytes(handler, target, mime, disposition, \"no-store\", csp=csp, anchor_root=anchor_root)" in escape_raw +def test_escape_raw_helper_reanchors_through_safe_resolve(): + src = WORKSPACE_PY.read_text(encoding="utf-8") + helper = _func_body(src, "raw_authorized_escape_target") + + assert "safe_resolve_ws(resolved[\"external_root\"], resolved[\"external_rel\"])" in helper + assert "return resolved[\"external_root\"], target" in helper + + def test_upload_archive_cleanup_uses_anchored_helpers(): src = UPLOAD_PY.read_text(encoding="utf-8") diff --git a/tests/test_workspace_symlink_containment.py b/tests/test_workspace_symlink_containment.py index 598c6ea7..6a97f95c 100644 --- a/tests/test_workspace_symlink_containment.py +++ b/tests/test_workspace_symlink_containment.py @@ -116,6 +116,38 @@ def test_authorized_escape_request_expires_when_surface_target_changes(tmp_path) resolve_authorized_escape_request(workspace, "sess-1", grant["token"], "escape") +def test_authorized_escape_request_keeps_other_live_grants(tmp_path): + workspace = tmp_path / "workspace" + outside_a = tmp_path / "outside-a" + outside_b = tmp_path / "outside-b" + workspace.mkdir() + outside_a.mkdir() + outside_b.mkdir() + (outside_a / "alpha.txt").write_text("alpha", encoding="utf-8") + (outside_b / "beta.txt").write_text("beta", encoding="utf-8") + (workspace / "escape-a").symlink_to(outside_a) + (workspace / "escape-b").symlink_to(outside_b) + + grant_a = authorize_escape_target(workspace, "sess-1", "escape-a") + grant_b = authorize_escape_target(workspace, "sess-1", "escape-b") + + resolved_a = resolve_authorized_escape_request( + workspace, + "sess-1", + grant_a["token"], + "escape-a/alpha.txt", + ) + resolved_b = resolve_authorized_escape_request( + workspace, + "sess-1", + grant_b["token"], + "escape-b/beta.txt", + ) + + assert resolved_a["target"] == outside_a / "alpha.txt" + assert resolved_b["target"] == outside_b / "beta.txt" + + def test_authorized_listing_keeps_nested_child_escape_display_only(tmp_path): workspace = tmp_path / "workspace" outside = tmp_path / "outside"