Preserve escape grants while locking second-hop regressions

This commit is contained in:
Rod Boev committed 2026-06-23 02:52:44 -04:00
1 parent 0f80113c12
commit 8caca99cae
4 files changed
+168 -7

No files matched your search

+1 -1
View File
@@ -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,
@@ -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)
+9
View File
@@ -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")
@@ -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"