diff --git a/api/routes.py b/api/routes.py index c2cf7a98..cac6371e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6614,6 +6614,7 @@ from api.workspace import ( set_last_workspace, git_info_for_workspace, authorize_escape_target, + EscapeAuthorizationExpiredError, list_dir, list_authorized_escape_dir, dir_signature, @@ -9825,9 +9826,6 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/list": return _handle_list_dir(handler, parsed) - if parsed.path == "/api/escape/authorize": - return _handle_escape_authorize(handler, parsed) - if parsed.path == "/api/escape/list": return _handle_escape_list_dir(handler, parsed) @@ -12995,10 +12993,10 @@ def _handle_escape_list_dir(handler, parsed): return j(handler, payload) except FileNotFoundError as exc: return bad(handler, _sanitize_error(exc), 404) + except EscapeAuthorizationExpiredError as exc: + return bad(handler, _sanitize_error(exc), 403) except ValueError as exc: - msg = _sanitize_error(exc) - status = 403 if "expired" in msg.lower() else 404 - return bad(handler, msg, status) + return bad(handler, _sanitize_error(exc), 404) def _handle_escape_file_read(handler, parsed): @@ -13018,10 +13016,10 @@ def _handle_escape_file_read(handler, parsed): return j(handler, read_authorized_escape_file_content(Path(s.workspace), sid, token, rel)) except FileNotFoundError as exc: return bad(handler, _sanitize_error(exc), 404) + except EscapeAuthorizationExpiredError as exc: + return bad(handler, _sanitize_error(exc), 403) except ValueError as exc: - msg = _sanitize_error(exc) - status = 403 if "expired" in msg.lower() else 404 - return bad(handler, msg, status) + return bad(handler, _sanitize_error(exc), 404) def _handle_escape_file_raw(handler, parsed): @@ -13042,10 +13040,10 @@ def _handle_escape_file_raw(handler, parsed): anchor_root, target = raw_authorized_escape_target(Path(s.workspace), sid, token, rel) except FileNotFoundError: return j(handler, {"error": "not found"}, status=404) + except EscapeAuthorizationExpiredError as exc: + return bad(handler, _sanitize_error(exc), 403) except ValueError as exc: - msg = _sanitize_error(exc) - status = 403 if "expired" in msg.lower() else 404 - return bad(handler, msg, status) + return bad(handler, _sanitize_error(exc), 404) if not target.exists() or not target.is_file(): return j(handler, {"error": "not found"}, status=404) ext = target.suffix.lower() diff --git a/api/workspace.py b/api/workspace.py index 6ed0b4b3..f3a82693 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -1461,7 +1461,13 @@ def _escape_virtual_path(root: str, rel: str) -> str: def _escape_surface_target(workspace: Path, rel: str) -> tuple[Path, Path]: workspace_root = workspace.resolve() surface_rel = _normalize_workspace_rel_path(rel) - surface_path = workspace / surface_rel + surface_posix = PurePosixPath(surface_rel) + parent_rel = str(surface_posix.parent) + if parent_rel in ("", "."): + parent_path = workspace_root + else: + parent_path = safe_resolve_ws(workspace_root, parent_rel) + surface_path = parent_path / surface_posix.name if not surface_path.is_symlink(): raise ValueError(f"Path is not an escape-target symlink: {rel}") target = surface_path.resolve() @@ -1485,6 +1491,10 @@ def _escape_authorized_root(target: Path) -> tuple[Path, str]: return resolved_target.parent, resolved_target.name +class EscapeAuthorizationExpiredError(ValueError): + pass + + def _escape_prune_tokens(now: float | None = None) -> None: cutoff = time.time() if now is None else now expired = [token for token, record in _ESCAPE_AUTH_TOKENS.items() if float(record.get("expires_at") or 0.0) <= cutoff] @@ -1531,25 +1541,21 @@ def _escape_authorization_record(workspace: Path, session_id: str, token: str) - _escape_prune_tokens(now) record = dict(_ESCAPE_AUTH_TOKENS.get(token) or {}) if not record: - raise ValueError("Escape authorization expired") + raise EscapeAuthorizationExpiredError("Escape authorization expired") if str(record.get("session_id") or "") != str(session_id or ""): - raise ValueError("Escape authorization expired") + raise EscapeAuthorizationExpiredError("Escape authorization expired") if str(record.get("workspace_root") or "") != workspace_root: - raise ValueError("Escape authorization expired") - if now >= float(record.get("expires_at") or 0.0): - with _ESCAPE_AUTH_LOCK: - _ESCAPE_AUTH_TOKENS.pop(token, None) - raise ValueError("Escape authorization expired") + raise EscapeAuthorizationExpiredError("Escape authorization expired") surface_path = _normalize_workspace_rel_path(record.get("surface_path") or ".") surface_target = str(record.get("surface_target") or "") try: _surface, current_target = _escape_surface_target(Path(workspace_root), surface_path) except ValueError: - raise ValueError("Escape authorization expired") from None + raise EscapeAuthorizationExpiredError("Escape authorization expired") from None if str(current_target.resolve()) != surface_target: - raise ValueError("Escape authorization expired") + raise EscapeAuthorizationExpiredError("Escape authorization expired") if not current_target.exists() or _is_blocked_system_path(current_target): - raise ValueError("Escape authorization expired") + raise EscapeAuthorizationExpiredError("Escape authorization expired") return record diff --git a/static/workspace.js b/static/workspace.js index 534a90c5..71d7ae5e 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -697,7 +697,7 @@ async function loadDir(path, opts={}){ if(!path||path==='.') _refreshGitBadge(); }catch(e){ const grant = _workspaceEscapeGrantForPath(path); - if(grant && (e && (e.status === 403 || e.status === 404))){ + if(grant && e && e.status===403){ _clearWorkspaceEscapeGrant(grant.path); showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error'); return; @@ -1095,7 +1095,7 @@ async function openFile(path, opts={}){ renderCodePreviewContent(path, data.content); }catch(e){ const grant = _workspaceEscapeGrantForPath(path); - if(grant && (e && (e.status === 403 || e.status === 404))){ + if(grant && e && e.status===403){ _clearWorkspaceEscapeGrant(grant.path); showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error'); return; diff --git a/tests/test_issue4582_workspace_escape_navigation.py b/tests/test_issue4582_workspace_escape_navigation.py index effb6229..a2dc241a 100644 --- a/tests/test_issue4582_workspace_escape_navigation.py +++ b/tests/test_issue4582_workspace_escape_navigation.py @@ -176,6 +176,12 @@ class TestIssue4582EscapeNavigationLive: auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"}) assert status == 200, auth + nested_auth, nested_status = _post_json( + "/api/escape/authorize", + {"session_id": sid, "path": "escape/nested-escape"}, + ) + assert nested_status in (403, 404), nested_auth + listed = _get_json( f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape" )