diff --git a/api/routes.py b/api/routes.py index 25796f0b..c1128738 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6613,11 +6613,15 @@ from api.workspace import ( get_last_workspace, set_last_workspace, git_info_for_workspace, + authorize_escape_target, list_dir, + list_authorized_escape_dir, dir_signature, list_workspace_suggestions, read_file_content, + read_authorized_escape_file_content, safe_resolve_ws, + raw_authorized_escape_target, resolve_trusted_workspace, open_anchored_fd, open_anchored_create_fd, @@ -9821,6 +9825,12 @@ 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) + if parsed.path == "/api/git/status": return _handle_git_status(handler, parsed) @@ -9971,12 +9981,18 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/file/raw": return _handle_file_raw(handler, parsed) + if parsed.path == "/api/escape/file/raw": + return _handle_escape_file_raw(handler, parsed) + if parsed.path == "/api/folder/download": return _handle_folder_download(handler, parsed) if parsed.path == "/api/file": return _handle_file_read(handler, parsed) + if parsed.path == "/api/escape/file/read": + return _handle_escape_file_read(handler, parsed) + if parsed.path == "/api/approval/pending": return _handle_approval_pending(handler, parsed) @@ -10479,6 +10495,9 @@ def handle_post(handler, parsed) -> bool: diag.finish() raise + if parsed.path == "/api/escape/authorize": + return _handle_escape_authorize(handler, parsed, body) + if parsed.path == "/api/updates/check": settings = load_settings() if not settings.get("check_for_updates", True): @@ -12916,6 +12935,132 @@ def _handle_list_dir(handler, parsed): return bad(handler, _sanitize_error(e), 404) +def _read_json_request_body(handler, *, max_bytes: int = 4096) -> dict: + try: + length = _safe_content_length(handler, max_bytes) + except (ValueError, OverflowError) as exc: + raise ValueError(_sanitize_error(exc)) from exc + raw = handler.rfile.read(length) if length else b"{}" + try: + payload = json.loads(raw.decode("utf-8")) + except Exception as exc: + raise ValueError("invalid JSON body") from exc + return payload if isinstance(payload, dict) else {} + + +def _handle_escape_authorize(handler, parsed, body: dict | None = None): + if handler.command != "POST": + return bad(handler, "method not allowed", 405) + if body is None: + try: + body = _read_json_request_body(handler) + except ValueError as exc: + return bad(handler, _sanitize_error(exc), 400) + qs = parse_qs(parsed.query) + sid = str(body.get("session_id") or qs.get("session_id", [""])[0] or "").strip() + rel = str(body.get("path") or qs.get("path", [""])[0] or "").strip() + token = str(body.get("token") or qs.get("token", [""])[0] or "").strip() + if token: + return bad(handler, "token must not be provided", 400) + if not sid: + return bad(handler, "session_id is required") + if not rel: + return bad(handler, "path is required") + try: + s = get_session_for_file_ops(sid) + except KeyError: + return bad(handler, "Session not found", 404) + try: + payload = authorize_escape_target(Path(s.workspace), sid, rel) + except ValueError as exc: + return bad(handler, _sanitize_error(exc), 404) + return j(handler, payload) + + +def _handle_escape_list_dir(handler, parsed): + qs = parse_qs(parsed.query) + sid = qs.get("session_id", [""])[0] + token = qs.get("token", [""])[0] + if not sid: + return bad(handler, "session_id is required") + if not token: + return bad(handler, "token is required") + try: + s = get_session_for_file_ops(sid) + except KeyError: + return bad(handler, "Session not found", 404) + rel_path = qs.get("path", ["."])[0] + try: + payload = list_authorized_escape_dir(Path(s.workspace), sid, token, rel_path) + return j(handler, payload) + except FileNotFoundError as exc: + return bad(handler, _sanitize_error(exc), 404) + except ValueError as exc: + msg = _sanitize_error(exc) + status = 403 if "expired" in msg.lower() else 404 + return bad(handler, msg, status) + + +def _handle_escape_file_read(handler, parsed): + qs = parse_qs(parsed.query) + sid = qs.get("session_id", [""])[0] + token = qs.get("token", [""])[0] + if not sid: + return bad(handler, "session_id is required") + if not token: + return bad(handler, "token is required") + try: + s = get_session_for_file_ops(sid) + except KeyError: + return bad(handler, "Session not found", 404) + rel = qs.get("path", [""])[0] + try: + 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 ValueError as exc: + msg = _sanitize_error(exc) + status = 403 if "expired" in msg.lower() else 404 + return bad(handler, msg, status) + + +def _handle_escape_file_raw(handler, parsed): + qs = parse_qs(parsed.query) + sid = qs.get("session_id", [""])[0] + token = qs.get("token", [""])[0] + if not sid: + return bad(handler, "session_id is required") + if not token: + return bad(handler, "token is required") + try: + s = get_session_for_file_ops(sid) + except KeyError: + return bad(handler, "Session not found", 404) + rel = qs.get("path", [""])[0] + force_download = qs.get("download", [""])[0] == "1" + try: + anchor_root, target = raw_authorized_escape_target(Path(s.workspace), sid, token, rel) + except FileNotFoundError as exc: + return j(handler, {"error": "not found"}, status=404) + except ValueError as exc: + msg = _sanitize_error(exc) + status = 403 if "expired" in msg.lower() else 404 + return bad(handler, msg, status) + if not target.exists() or not target.is_file(): + return j(handler, {"error": "not found"}, status=404) + ext = target.suffix.lower() + mime = MIME_MAP.get(ext, "application/octet-stream") + inline_preview = qs.get("inline", [""])[0] == "1" + dangerous_types = {"text/html", "application/xhtml+xml", "image/svg+xml"} + html_inline_ok = inline_preview and mime == "text/html" + disposition = "attachment" if force_download or (mime in dangerous_types and not html_inline_ok) else "inline" + sandbox_csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" + csp = sandbox_csp if (inline_preview and not force_download and disposition == "inline") else None + if html_inline_ok: + return _serve_inline_html_preview(handler, target, "no-store", csp=sandbox_csp, anchor_root=anchor_root) + return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp, anchor_root=anchor_root) + + def _sse_with_id(handler, event, data, event_id=None): if event_id: handler.wfile.write(f"id: {event_id}\n".encode("utf-8")) diff --git a/api/workspace.py b/api/workspace.py index f736d25a..1f6bd30f 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -12,14 +12,21 @@ import json import logging import os import posixpath +import secrets import shutil import stat import subprocess import concurrent.futures +import threading +import time from pathlib import Path, PurePosixPath logger = logging.getLogger(__name__) +_ESCAPE_AUTH_TTL_SECONDS = 300 +_ESCAPE_AUTH_LOCK = threading.Lock() +_ESCAPE_AUTH_TOKENS: dict[str, dict[str, str | int | float]] = {} + from api.config import ( WORKSPACES_FILE as _GLOBAL_WS_FILE, LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE, @@ -1429,6 +1436,180 @@ def read_file_content(workspace: Path, rel: str) -> dict: return {'path': rel, 'content': content, 'size': len(raw), 'lines': content.count('\n') + 1} +def _normalize_workspace_rel_path(rel: str | Path) -> str: + raw = _strip_surrounding_quotes(str(rel or "")).strip().replace("\\", "/") + if not raw or raw == ".": + return "." + norm = posixpath.normpath(raw) + if not norm or norm == ".": + return "." + if norm == ".." or norm.startswith("../") or norm.startswith("/"): + raise ValueError(f"Path traversal blocked: {rel}") + return norm + + +def _escape_virtual_path(root: str, rel: str) -> str: + root_norm = _normalize_workspace_rel_path(root) + rel_norm = _normalize_workspace_rel_path(rel) + if root_norm == ".": + return rel_norm + if rel_norm == ".": + return root_norm + return f"{root_norm}/{rel_norm}" + + +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 + if not surface_path.is_symlink(): + raise ValueError(f"Path is not an escape-target symlink: {rel}") + target = surface_path.resolve() + if not target.exists(): + raise ValueError(f"Path is no longer reachable: {rel}") + try: + target.relative_to(workspace_root) + except ValueError: + pass + else: + raise ValueError(f"Path does not escape workspace: {rel}") + if _is_blocked_system_path(target): + raise ValueError(f"Path points to a system directory: {target}") + return surface_path, target + + +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] + for token in expired: + _ESCAPE_AUTH_TOKENS.pop(token, None) + + +def authorize_escape_target(workspace: Path, session_id: str, rel: str) -> dict: + """Mint a short-lived browser-only grant for one surfaced escape-target symlink.""" + workspace_root = workspace.resolve() + _surface_path, target = _escape_surface_target(workspace_root, rel) + token = secrets.token_urlsafe(24) + expires_at = time.time() + _ESCAPE_AUTH_TTL_SECONDS + record = { + "session_id": str(session_id or ""), + "workspace_root": str(workspace_root), + "surface_path": _normalize_workspace_rel_path(rel), + "external_root": str(target), + "surface_target": str(target), + "expires_at": expires_at, + } + with _ESCAPE_AUTH_LOCK: + _escape_prune_tokens(expires_at) + _ESCAPE_AUTH_TOKENS[token] = record + return { + "token": token, + "path": record["surface_path"], + "is_dir": target.is_dir(), + "expires_at": expires_at, + "expires_in": _ESCAPE_AUTH_TTL_SECONDS, + "read_only": True, + } + + +def _escape_authorization_record(workspace: Path, session_id: str, token: str) -> dict: + workspace_root = str(workspace.resolve()) + token = str(token or "").strip() + if not token: + raise ValueError("Escape authorization token is required") + now = time.time() + with _ESCAPE_AUTH_LOCK: + _escape_prune_tokens(now) + record = dict(_ESCAPE_AUTH_TOKENS.get(token) or {}) + if not record: + raise ValueError("Escape authorization expired") + if str(record.get("session_id") or "") != str(session_id or ""): + raise ValueError("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") + surface_path = _normalize_workspace_rel_path(record.get("surface_path") or ".") + external_root = Path(str(record.get("external_root") or "")) + try: + _surface, surface_target = _escape_surface_target(Path(workspace_root), surface_path) + except ValueError: + raise ValueError("Escape authorization expired") + if str(surface_target.resolve()) != str(external_root.resolve()): + raise ValueError("Escape authorization expired") + if not surface_target.exists() or _is_blocked_system_path(surface_target): + raise ValueError("Escape authorization expired") + return record + + +def resolve_authorized_escape_request(workspace: Path, session_id: str, token: str, rel: str) -> dict: + record = _escape_authorization_record(workspace, session_id, token) + surface_path = _normalize_workspace_rel_path(record["surface_path"]) + request_path = _normalize_workspace_rel_path(rel) + try: + external_rel = str(PurePosixPath(request_path).relative_to(PurePosixPath(surface_path))) + except ValueError: + raise ValueError(f"Path traversal blocked: {rel}") + if not external_rel: + external_rel = "." + external_root = Path(str(record["external_root"])) + target = external_root / external_rel + return { + "record": record, + "workspace_root": Path(str(record["workspace_root"])), + "surface_path": surface_path, + "request_path": request_path, + "external_rel": external_rel, + "external_root": external_root, + "target": target, + } + + +def list_authorized_escape_dir(workspace: Path, session_id: str, token: str, rel: str) -> dict: + resolved = resolve_authorized_escape_request(workspace, session_id, token, rel) + external_root = resolved["external_root"] + external_rel = resolved["external_rel"] + entries = list_dir(external_root, external_rel) + surface_path = resolved["surface_path"] + external_root_resolved = external_root.resolve() + for entry in entries: + entry["path"] = _escape_virtual_path(surface_path, entry.get("path") or ".") + entry["escape_read_only"] = True + target = entry.get("target") + if not target: + continue + try: + target_path = Path(str(target)).resolve() + target_rel = target_path.relative_to(external_root_resolved).as_posix() + except Exception: + entry.pop("target", None) + continue + entry["target"] = _escape_virtual_path(surface_path, target_rel) + return { + "path": resolved["request_path"], + "entries": entries, + "signature": dir_signature(external_root, external_rel, entries), + "virtual_root": surface_path, + "read_only": True, + } + + +def read_authorized_escape_file_content(workspace: Path, session_id: str, token: str, rel: str) -> dict: + resolved = resolve_authorized_escape_request(workspace, session_id, token, rel) + payload = read_file_content(resolved["external_root"], resolved["external_rel"]) + payload["path"] = resolved["request_path"] + payload["escape_read_only"] = True + return payload + + +def raw_authorized_escape_target(workspace: Path, session_id: str, token: str, rel: str) -> tuple[Path, Path]: + resolved = resolve_authorized_escape_request(workspace, session_id, token, rel) + target = safe_resolve_ws(resolved["external_root"], resolved["external_rel"]) + return resolved["external_root"], target + + # ── Git detection ────────────────────────────────────────────────────────── def _run_git(args, cwd, timeout=3): diff --git a/static/i18n.js b/static/i18n.js index bbe6ec5d..211ae4a0 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -447,7 +447,9 @@ const LOCALES = { downloading: (name) => `Downloading ${name}\u2026`, double_click_rename: 'Double-click to rename', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', @@ -2020,7 +2022,9 @@ const LOCALES = { downloading: (name) => `Scaricamento ${name}\u2026`, double_click_rename: 'Doppio clic per rinominare', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Rinominato in ', rename_failed: 'Rinomina fallita: ', @@ -3584,7 +3588,9 @@ const LOCALES = { downloading: (name) => `${name} をダウンロード中…`, double_click_rename: 'ダブルクリックで名前変更', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。', renamed_to: '名前を変更: ', rename_failed: '名前変更失敗: ', @@ -5072,7 +5078,9 @@ const LOCALES = { downloading: (name) => `Скачиваю ${name}…`, double_click_rename: 'Дважды щёлкните, чтобы переименовать', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Переименовано в ', rename_failed: 'Не удалось переименовать: ', @@ -6553,7 +6561,9 @@ const LOCALES = { downloading: (name) => `Descargando ${name}…`, double_click_rename: 'Haz doble clic para renombrar', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renombrado a ', rename_failed: 'Error al renombrar: ', @@ -8032,7 +8042,9 @@ const LOCALES = { downloading: (name) => `Lade ${name} herunter\u2026`, double_click_rename: 'Doppelklick zum Umbenennen', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Umbenannt in ', rename_failed: 'Umbenennen fehlgeschlagen: ', @@ -9576,7 +9588,9 @@ const LOCALES = { downloading: (name) => `正在下载 ${name}...`, double_click_rename: '双击重命名', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: '已重命名为 ', rename_failed: '重命名失败:', @@ -11154,7 +11168,9 @@ const LOCALES = { downloading: (name) => `正在下載 ${name}…`, double_click_rename: '按兩下即可重新命名', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: '無法開始重新命名,找不到資料列。', renamed_to: '已重新命名為 ', rename_failed: '重新命名失敗:', @@ -12621,7 +12637,9 @@ const LOCALES = { downloading: (name) => `Baixando ${name}…`, double_click_rename: 'Duplo clique para renomear', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renomeado para ', rename_failed: 'Falha ao renomear: ', @@ -14079,7 +14097,9 @@ const LOCALES = { downloading: (name) => `Downloading ${name}\u2026`, double_click_rename: 'Double-click to rename', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', @@ -15582,7 +15602,9 @@ const LOCALES = { file_open_failed: 'Impossible d\'ouvrir le fichier', double_click_rename: 'Double-cliquez pour renommer', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renommé en', rename_failed: 'Échec du changement de nom :', @@ -17151,7 +17173,9 @@ const LOCALES = { downloading: (name) => `${name} indiriliyor\u2026`, double_click_rename: 'Yeniden adlandırmak için çift tıklayın', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Yeniden adlandırıldı', rename_failed: 'Yeniden adlandırma başarısız oldu:', @@ -18729,7 +18753,9 @@ const LOCALES = { downloading: (name) => `Pobieranie ${name}…`, double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę', symlink_link_to: 'Symlink → {target}', - external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', + external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?', + external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.', + external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.', session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.', renamed_to: 'Nazwa zmieniona na ', rename_failed: 'Zmiana nazwy nie powiodła się: ', diff --git a/static/ui.js b/static/ui.js index 1c9f759c..adeb85de 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14631,18 +14631,27 @@ function _renderTreeItems(container, entries, depth){ el.style.paddingLeft=(8+depth*16)+'px'; el.setAttribute('draggable','true'); el.dataset.wsType=item.type; - el.oncontextmenu=(e)=>{e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);}; + el.oncontextmenu=(e)=>{ + const grant=typeof _workspaceEscapeGrantForPath==='function' ? _workspaceEscapeGrantForPath(item.path) : null; + const isDirRow=item.type==='dir'||(item.type==='symlink'&&item.is_dir); + if(grant&&!isDirRow){e.preventDefault();e.stopPropagation();return;} + e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item); + }; el.ondragstart=(e)=>{e.dataTransfer.setData('application/ws-path',item.path);e.dataTransfer.setData('application/ws-type',item.type);e.dataTransfer.effectAllowed='copy';el.classList.add('dragging');}; el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();}; const isLk = item.type === 'symlink'; const isExternalLink = isLk && item.target_outside_workspace; + const escapeGrant = typeof _workspaceEscapeGrantForPath === 'function' ? _workspaceEscapeGrantForPath(item.path) : null; + const exactEscapeGrant = typeof _workspaceEscapeExactGrant === 'function' ? _workspaceEscapeExactGrant(item.path) : null; + const isReadOnlyEscape = !!escapeGrant; + const isNestedEscape = !!escapeGrant && !exactEscapeGrant; // External symlinks are display-only: not expandable, not openable. // The read gate (safe_resolve_ws) still blocks navigation through them. const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir)); const isFileLike = !isExternalLink && !isDirLike; el.dataset.wsIsDir = String(isDirLike); - if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;} + if(isExternalLink || isReadOnlyEscape){el.removeAttribute('draggable');el.ondragstart=null;} if(isDirLike){ // Toggle arrow for directories @@ -14678,6 +14687,10 @@ function _renderTreeItems(container, entries, depth){ // (the "Double-click to rename" hint here would be misleading). #1710. if(isLk && item.target) nameEl.title = t('symlink_link_to').replace('{target}', () => elideMiddle(item.target)); + else if(isExternalLink) + nameEl.title = isReadOnlyEscape ? t('external_link_read_only') : t('external_link_open_confirm'); + else if(isReadOnlyEscape) + nameEl.title = t('external_link_read_only'); else if(!isDirLike) nameEl.title = t('double_click_rename'); // Single-click opens (file) or expand-toggles (dir) but is debounced 300ms so a @@ -14699,8 +14712,12 @@ function _renderTreeItems(container, entries, depth){ if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;} // For directories, double-click navigates (breadcrumb view) if(isDirLike){loadDir(item.path);return;} - // External symlinks: show informational dialog, not rename - if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;} + // Escape-root rows remain browse-only, nested escape rows stay display-only. + if(isReadOnlyEscape){ + if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;} + openFile(item.path); + return; + } const inp=document.createElement('input'); inp.className='file-rename-input';inp.value=item.name; inp.onclick=(e2)=>e2.stopPropagation(); @@ -14754,12 +14771,12 @@ function _renderTreeItems(container, entries, depth){ } // Delete button -- for file-like rows and directory-like rows - if(isFileLike){ + if(isFileLike&& !isReadOnlyEscape){ const del=document.createElement('button'); del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7'; del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);}; el.appendChild(del); - }else if(isDirLike){ + }else if(isDirLike&& !isReadOnlyEscape){ const del=document.createElement('button'); del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7'; del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceDir(item.path,item.name);}; @@ -14767,8 +14784,10 @@ function _renderTreeItems(container, entries, depth){ } if(isDirLike){ - _bindWorkspaceMoveDropTarget(el,item.path); - _bindWorkspaceOsUploadDropTarget(el,item.path); + if(!isReadOnlyEscape){ + _bindWorkspaceMoveDropTarget(el,item.path); + _bindWorkspaceOsUploadDropTarget(el,item.path); + } // Single-click toggles expand/collapse el.onclick=async(e)=>{ e.stopPropagation(); @@ -14782,7 +14801,7 @@ function _renderTreeItems(container, entries, depth){ // Fetch children if not cached if(!S._dirCache[item.path]){ try{ - const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(item.path)}`); + const data=await api(_workspaceRouteForPath(item.path, 'list')); S._dirCache[item.path]=data.entries||[]; }catch(e2){S._dirCache[item.path]=[];} } @@ -14791,18 +14810,25 @@ function _renderTreeItems(container, entries, depth){ }; }else if(isExternalLink){ // Display-only: the link points outside the workspace. We do NOT disclose - // the resolved outside path (#4581 hardening) and do NOT call openFile — - // the read gate (safe_resolve_ws) blocks navigation through the link. + // the resolved outside path (#4581 hardening) and do NOT recursively + // authorize nested escape rows under an already-authorized external root. el.onclick=async(e)=>{ e.stopPropagation(); - await showConfirmDialog({ - title:item.name, - message:t('external_link_open_confirm'), - confirmLabel:t('dialog_confirm_btn'), - danger:false, - hideCancel:true, - focusCancel:false, - }); + if(isNestedEscape){ + await showConfirmDialog({ + title:item.name, + message:t('external_link_read_only'), + confirmLabel:t('dialog_confirm_btn'), + danger:false, + hideCancel:true, + focusCancel:false, + }); + return; + } + const grant = await authorizeWorkspaceEscapeNavigation(item); + if(!grant) return; + if(grant.isDir) await loadDir(item.path); + else await openFile(item.path); }; }else{ el.onclick=async()=>openFile(item.path); @@ -14828,6 +14854,10 @@ function _renderTreeItems(container, entries, depth){ async function deleteWorkspaceDir(relPath, name){ if(!S.session)return; + if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(relPath)){ + showToast(t('external_link_read_only'), 2000); + return; + } const ok=await showConfirmDialog({title:t('delete_dir_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true}); if(!ok)return; try{ @@ -14851,83 +14881,86 @@ function _showFileContextMenu(e, item){ menu.style.top=(e.clientY+100>vh?e.clientY-100:e.clientY)+'px'; const isDirLike=item.type==='dir'||(item.type==='symlink'&&item.is_dir); const targetDir=isDirLike ? item.path : _workspaceParentDir(item.path); + const isReadOnlyEscape=typeof _workspaceEscapeGrantForPath==='function' ? !!_workspaceEscapeGrantForPath(item.path) : false; - menu.appendChild(_workspaceContextMenuItem(t('new_file'),async()=>{ - menu.remove(); - await promptNewFile(targetDir); - })); + if(!isReadOnlyEscape){ + menu.appendChild(_workspaceContextMenuItem(t('new_file'),async()=>{ + menu.remove(); + await promptNewFile(targetDir); + })); - menu.appendChild(_workspaceContextMenuItem(t('new_folder'),async()=>{ - menu.remove(); - await promptNewFolder(targetDir); - })); + menu.appendChild(_workspaceContextMenuItem(t('new_folder'),async()=>{ + menu.remove(); + await promptNewFolder(targetDir); + })); - const createSep=document.createElement('hr'); - createSep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; - menu.appendChild(createSep); + const createSep=document.createElement('hr'); + createSep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; + menu.appendChild(createSep); - // Rename - const renameItem=document.createElement('div'); - renameItem.textContent=t('rename_title'); - renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)'; - renameItem.onmouseleave=()=>renameItem.style.background=''; - renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);}; - menu.appendChild(renameItem); + // Rename + const renameItem=document.createElement('div'); + renameItem.textContent=t('rename_title'); + renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)'; + renameItem.onmouseleave=()=>renameItem.style.background=''; + renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);}; + menu.appendChild(renameItem); - // Reveal in File Manager - const revealItem=document.createElement('div'); - revealItem.textContent=t('reveal_in_finder'); - revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - revealItem.onmouseenter=()=>revealItem.style.background='var(--hover-bg)'; - revealItem.onmouseleave=()=>revealItem.style.background=''; - revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; - menu.appendChild(revealItem); + // Reveal in File Manager + const revealItem=document.createElement('div'); + revealItem.textContent=t('reveal_in_finder'); + revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + revealItem.onmouseenter=()=>revealItem.style.background='var(--hover-bg)'; + revealItem.onmouseleave=()=>revealItem.style.background=''; + revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; + menu.appendChild(revealItem); - // Open in VS Code (#2735) - const vscodeItem=document.createElement('div'); - vscodeItem.textContent=t('open_in_vscode'); - vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)'; - vscodeItem.onmouseleave=()=>vscodeItem.style.background=''; - vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}}; - menu.appendChild(vscodeItem); + // Open in VS Code (#2735) + const vscodeItem=document.createElement('div'); + vscodeItem.textContent=t('open_in_vscode'); + vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)'; + vscodeItem.onmouseleave=()=>vscodeItem.style.background=''; + vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}}; + menu.appendChild(vscodeItem); - // Copy file path — resolves the absolute on-disk path on the server (so the - // user gets the full /home/.../workspace/foo.py rather than the relative - // path the file tree shows) and writes it to the OS clipboard. Useful for - // pasting into terminals, editors, or other apps without taking the slower - // Reveal-in-Finder round trip. - const copyPathItem=document.createElement('div'); - copyPathItem.textContent=t('copy_file_path'); - copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; - copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover-bg)'; - copyPathItem.onmouseleave=()=>copyPathItem.style.background=''; - copyPathItem.onclick=async()=>{ - menu.remove(); - try{ - const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})}); - const abs=(r&&r.path)||item.path; + // Copy file path — resolves the absolute on-disk path on the server (so the + // user gets the full /home/.../workspace/foo.py rather than the relative + // path the file tree shows) and writes it to the OS clipboard. Useful for + // pasting into terminals, editors, or other apps without taking the slower + // Reveal-in-Finder round trip. + const copyPathItem=document.createElement('div'); + copyPathItem.textContent=t('copy_file_path'); + copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover-bg)'; + copyPathItem.onmouseleave=()=>copyPathItem.style.background=''; + copyPathItem.onclick=async()=>{ + menu.remove(); try{ - await navigator.clipboard.writeText(abs); - showToast(t('path_copied')); - }catch(clipErr){ - const ta=document.createElement('textarea'); - ta.value=abs; - ta.style.cssText='position:fixed;left:-9999px;top:-9999px;'; - document.body.appendChild(ta); - ta.select(); - let copied=false; - try{copied=document.execCommand('copy');}catch(_){} - ta.remove(); - if(copied) showToast(t('path_copied')); - else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr))); + const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})}); + const abs=(r&&r.path)||item.path; + try{ + await navigator.clipboard.writeText(abs); + showToast(t('path_copied')); + }catch(clipErr){ + const ta=document.createElement('textarea'); + ta.value=abs; + ta.style.cssText='position:fixed;left:-9999px;top:-9999px;'; + document.body.appendChild(ta); + ta.select(); + let copied=false; + try{copied=document.execCommand('copy');}catch(_){} + ta.remove(); + if(copied) showToast(t('path_copied')); + else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr))); + } + }catch(err){ + showToast(t('path_copy_failed')+(err.message||err)); } - }catch(err){ - showToast(t('path_copy_failed')+(err.message||err)); - } - }; - menu.appendChild(copyPathItem); + }; + menu.appendChild(copyPathItem); + } if(isDirLike){ const dlItem=document.createElement('div'); @@ -14944,16 +14977,18 @@ function _showFileContextMenu(e, item){ menu.appendChild(dlItem); } - const sep=document.createElement('hr'); - sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; - menu.appendChild(sep); - const delItem=document.createElement('div'); - delItem.textContent=t('delete_title'); - delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);'; - delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)'; - delItem.onmouseleave=()=>delItem.style.background=''; - delItem.onclick=()=>{menu.remove();if(isDirLike)deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);}; - menu.appendChild(delItem); + if(!isReadOnlyEscape){ + const sep=document.createElement('hr'); + sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; + menu.appendChild(sep); + const delItem=document.createElement('div'); + delItem.textContent=t('delete_title'); + delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);'; + delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)'; + delItem.onmouseleave=()=>delItem.style.background=''; + delItem.onclick=()=>{menu.remove();if(isDirLike)deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);}; + menu.appendChild(delItem); + } document.body.appendChild(menu); const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);}; @@ -14962,6 +14997,10 @@ function _showFileContextMenu(e, item){ async function _inlineRenameFileItem(item){ if(!S.session)return; + if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(item.path)){ + showToast(t('external_link_read_only'), 2000); + return; + } const isDirLike=item.type==='dir'||(item.type==='symlink'&&item.is_dir); // Pre-fill the input with the current name and select just the stem // (everything before the last '.') so the user can immediately retype the @@ -14995,6 +15034,10 @@ async function _inlineRenameFileItem(item){ async function deleteWorkspaceFile(relPath, name){ if(!S.session)return; + if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(relPath)){ + showToast(t('external_link_read_only'), 2000); + return; + } const _delFile=await showConfirmDialog({title:t('delete_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true}); if(!_delFile) return; try{ @@ -15016,6 +15059,10 @@ async function promptNewFile(targetDir = S.currentDir || '.'){ }catch(e){setStatus(t('create_failed')+e.message);return;} } if(!S.session)return; + if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(targetDir)){ + showToast(t('external_link_read_only'), 2000); + return; + } const targetLabel=_workspaceCreateTargetLabel(targetDir); const name=await showPromptDialog({ title:t('new_file_prompt_title', targetLabel), @@ -15043,6 +15090,10 @@ async function promptNewFolder(targetDir = S.currentDir || '.'){ }catch(e){setStatus(t('folder_create_failed')+e.message);return;} } if(!S.session)return; + if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(targetDir)){ + showToast(t('external_link_read_only'), 2000); + return; + } const targetLabel=_workspaceCreateTargetLabel(targetDir); const name=await showPromptDialog({ title:t('new_folder_prompt_title', targetLabel), diff --git a/static/workspace.js b/static/workspace.js index 17b051e2..bcbcda11 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -138,6 +138,147 @@ function _restoreExpandedDirs(){ }catch(e){S._expandedDirs=new Set();} } +function _escapeGrantStore(){ + if(!S._escapeGrants) S._escapeGrants = Object.create(null); + return S._escapeGrants; +} + +function _normalizeWorkspaceRelPath(path){ + let raw = String(path || '').trim().replace(/\\/g, '/'); + if(!raw || raw === '.') return '.'; + if(raw.startsWith('/')) return ''; + const parts = []; + for(const part of raw.split('/')){ + if(!part || part === '.') continue; + if(part === '..'){ + if(parts.length) parts.pop(); + else return ''; + continue; + } + parts.push(part); + } + return parts.length ? parts.join('/') : '.'; +} + +function _isSameOrChildPath(base, path){ + const normalizedBase = _normalizeWorkspaceRelPath(base); + const normalizedPath = _normalizeWorkspaceRelPath(path); + if(!normalizedBase || !normalizedPath) return false; + if(normalizedBase === '.') return true; + return normalizedPath === normalizedBase || normalizedPath.startsWith(`${normalizedBase}/`); +} + +function _workspaceEscapeGrantForPath(path){ + const grants = _escapeGrantStore(); + const normalizedPath = _normalizeWorkspaceRelPath(path); + if(!normalizedPath || !S.session || !S.session.session_id) return null; + const sessionId = S.session.session_id; + let best = null; + for(const root of Object.keys(grants)){ + const grant = grants[root]; + if(!grant || grant.sessionId !== sessionId) continue; + if(grant.expiresAt && Date.now() >= grant.expiresAt){ + delete grants[root]; + continue; + } + if(!_isSameOrChildPath(root, normalizedPath)) continue; + if(!best || root.length > best.root.length) best = {root, grant}; + } + return best ? best.grant : null; +} + +function _workspaceEscapeExactGrant(path){ + const normalizedPath = _normalizeWorkspaceRelPath(path); + const grant = _workspaceEscapeGrantForPath(normalizedPath); + if(!grant) return null; + return grant.path === normalizedPath ? grant : null; +} + +function _storeWorkspaceEscapeGrant(data){ + if(!S.session || !data || !data.token) return null; + const grants = _escapeGrantStore(); + const root = _normalizeWorkspaceRelPath(data.path || ''); + if(!root) return null; + const grant = { + sessionId: S.session.session_id, + path: root, + token: String(data.token), + expiresAt: Number(data.expires_at || 0) * 1000, + isDir: !!data.is_dir, + }; + grants[root] = grant; + return grant; +} + +function _clearWorkspaceEscapeGrant(path){ + const grants = S._escapeGrants; + if(!grants) return; + const root = _normalizeWorkspaceRelPath(path); + if(root && grants[root]) delete grants[root]; +} + +function _workspacePathIsReadOnly(path){ + return !!_workspaceEscapeGrantForPath(path || S.currentDir || '.'); +} + +function _workspaceRouteForPath(path, kind, opts={}){ + if(!S.session) return ''; + const normalizedPath = _normalizeWorkspaceRelPath(path); + const grant = _workspaceEscapeGrantForPath(normalizedPath); + const sessionId = encodeURIComponent(S.session.session_id); + const params = new URLSearchParams({session_id:S.session.session_id, path:normalizedPath || '.'}); + if(grant){ + params.set('token', grant.token); + if(kind === 'raw' && opts.download) params.set('download', '1'); + if(kind === 'raw' && opts.inline) params.set('inline', '1'); + if(kind === 'list') return `/api/escape/list?${params.toString()}`; + if(kind === 'read') return `/api/escape/file/read?${params.toString()}`; + if(kind === 'raw') return `/api/escape/file/raw?${params.toString()}`; + } + if(kind === 'list') return `/api/list?session_id=${sessionId}&path=${encodeURIComponent(normalizedPath || '.')}`; + if(kind === 'read') return `/api/file?session_id=${sessionId}&path=${encodeURIComponent(normalizedPath || '.')}`; + if(kind === 'raw'){ + const extra = []; + if(opts.download) extra.push('download=1'); + if(opts.inline) extra.push('inline=1'); + const suffix = extra.length ? `&${extra.join('&')}` : ''; + return `/api/file/raw?session_id=${sessionId}&path=${encodeURIComponent(normalizedPath || '.')}${suffix}`; + } + return ''; +} + +async function authorizeWorkspaceEscapeNavigation(item){ + if(!S.session || !item || !item.path) return null; + const normalizedPath = _normalizeWorkspaceRelPath(item.path); + const exactGrant = _workspaceEscapeExactGrant(normalizedPath); + if(exactGrant) return exactGrant; + const ok = await showConfirmDialog({ + title: item.name || normalizedPath, + message: t('external_link_open_confirm'), + confirmLabel: t('dialog_confirm_btn'), + danger: false, + hideCancel: true, + focusCancel: false, + }); + if(!ok) return null; + try{ + const data = await api('/api/escape/authorize', { + method: 'POST', + body: JSON.stringify({ + session_id: S.session.session_id, + path: normalizedPath, + }), + }); + const grant = _storeWorkspaceEscapeGrant(data); + if(!grant) throw new Error('Missing escape authorization token'); + showToast(t('external_link_read_only'), 2000); + return grant; + }catch(e){ + showToast(t('external_link_grant_expired') || (e && e.message ? e.message : String(e)), 5000, 'error'); + return null; + } +} + let _workspacePanelActiveTab = 'files'; let _renderSessionArtifactsTimer = null; @@ -518,7 +659,7 @@ async function loadDir(path, opts={}){ _restoreExpandedDirs(); // restore per-workspace expanded state after root and refresh resets } S.currentDir=path||'.'; - const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`); + const data=await api(_workspaceRouteForPath(path, 'list')); if(!S.session||S.session.session_id!==sessionId||treeGen!==_wsTreeGen)return; S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); // #2673 — refresh Artifacts tab when its source data (the file tree) updates. @@ -530,7 +671,7 @@ async function loadDir(path, opts={}){ const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]); if(pending.length){ const results=await Promise.all(pending.map(dirPath=> - api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(dirPath)}`) + api(_workspaceRouteForPath(dirPath, 'list')) .then(dc=>({dirPath,entries:dc.entries||[]})) .catch(()=>({dirPath,entries:[]})) )); @@ -550,7 +691,15 @@ async function loadDir(path, opts={}){ } // Fetch git info for workspace root (non-blocking) if(!path||path==='.') _refreshGitBadge(); - }catch(e){console.warn('loadDir',e);} + }catch(e){ + const grant = _workspaceEscapeGrantForPath(path); + if(grant && (e && (e.status === 403 || e.status === 404))){ + _clearWorkspaceEscapeGrant(grant.path); + showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error'); + return; + } + console.warn('loadDir',e); + } } function refreshWorkspacePanel(){ @@ -732,7 +881,8 @@ function showPreview(mode){ function updateEditBtn(){ const btn=$('btnEditFile'); if(!btn)return; - const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv'; + const editable = !_workspacePathIsReadOnly(_previewCurrentPath) + && (_previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv'); btn.style.display = editable?'':'none'; const editing = $('previewEditArea').style.display!=='none'; btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`; @@ -743,6 +893,10 @@ function updateEditBtn(){ async function toggleEditMode(){ const editing = $('previewEditArea').style.display!=='none'; + if(_workspacePathIsReadOnly(_previewCurrentPath)){ + showToast(t('external_link_read_only'), 2000); + return; + } if(editing){ // Save if(!S.session||!_previewCurrentPath)return; @@ -851,14 +1005,14 @@ async function openFile(path, opts={}){ if(IMAGE_EXTS.has(ext)){ // Image: load via raw endpoint, show as showPreview('image'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}${cacheBust}`; + const url=_workspaceRouteForPath(path, 'raw') + cacheBust; $('previewImg').alt=path; $('previewImg').src=url; $('previewImg').onerror=()=>setStatus(t('image_load_failed')); } else if(AUDIO_EXTS.has(ext)||VIDEO_EXTS.has(ext)){ const mode=VIDEO_EXTS.has(ext)?'video':'audio'; showPreview(mode); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`; + const url=_workspaceRouteForPath(path, 'raw', {inline:true}) + cacheBust; const wrap=$('previewMediaWrap'); if(wrap){ wrap.innerHTML=(typeof _mediaPlayerHtml==='function') @@ -868,7 +1022,7 @@ async function openFile(path, opts={}){ } } else if(PDF_EXTS.has(ext)){ showPreview('pdf'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`; + const url=_workspaceRouteForPath(path, 'raw', {inline:true}) + cacheBust; const frame=$('previewPdfFrame'); if(frame){ frame.src=''; // clear first to avoid stale content @@ -885,7 +1039,7 @@ async function openFile(path, opts={}){ // file switch could re-render the previous file's cached content. const data=forceRichMarkdown&&path===_previewRawContentPath&&_previewRawContent ? {content:_previewRawContent} - : await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + : await api(_workspaceRouteForPath(path, 'read')); _previewRawContent = data.content; _previewRawContentPath = path; if(!forceRichMarkdown && shouldRenderMarkdownPreviewAsPlainText(data.content)){ @@ -907,7 +1061,7 @@ async function openFile(path, opts={}){ // or reading other origin data. If a stricter mode is needed, remove // allow-scripts (or add sandbox="") to disable all JS execution. showPreview('html'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`; + const url=_workspaceRouteForPath(path, 'raw', {inline:true}) + cacheBust; const iframe=$('previewHtmlIframe'); if(iframe){ iframe.src=''; // clear first to avoid stale content @@ -915,7 +1069,7 @@ async function openFile(path, opts={}){ } } else if(ext==='.csv'){ try{ - const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + const data=await api(_workspaceRouteForPath(path, 'read')); if(data.binary){ downloadFile(path); return; @@ -928,14 +1082,20 @@ async function openFile(path, opts={}){ } else { // Plain code / text -- but fall back to download if server signals binary try{ - const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + const data=await api(_workspaceRouteForPath(path, 'read')); if(data.binary){ // Server flagged this as binary content downloadFile(path); return; } renderCodePreviewContent(path, data.content); - }catch(e){ + }catch(e){ + const grant = _workspaceEscapeGrantForPath(path); + if(grant && (e && (e.status === 403 || e.status === 404))){ + _clearWorkspaceEscapeGrant(grant.path); + showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error'); + return; + } // If it's a 400/too-large error, offer download instead downloadFile(path); } @@ -945,7 +1105,7 @@ async function openFile(path, opts={}){ function downloadFile(path){ if(!S.session)return; // Trigger browser download via the raw file endpoint with content-disposition attachment - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`; + const url=_workspaceRouteForPath(path, 'raw', {download:true}); const filename=path.split('/').pop(); const a=document.createElement('a'); a.href=url;a.download=filename; @@ -995,12 +1155,16 @@ function renderFileBreadcrumb(filePath) { function openInBrowser(){ if(!_previewCurrentPath||!S.session) return; - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}&inline=1`; + const url=_workspaceRouteForPath(_previewCurrentPath, 'raw', {inline:true}); window.open(url,'_blank','noopener'); } // ── Workspace upload ────────────────────────────────────────────────── function triggerWorkspaceUpload() { + if(_workspacePathIsReadOnly(S.currentDir || '.')){ + showToast(t('external_link_read_only'), 2000); + return; + } const input = $('workspaceFileInput'); if (!input) return; input.value = ''; @@ -1017,6 +1181,10 @@ function triggerWorkspaceUpload() { async function uploadToWorkspace(file, dir) { if (!S.session) return; + if(_workspacePathIsReadOnly(dir || '.')){ + showToast(t('external_link_read_only'), 2000); + return; + } const formData = new FormData(); formData.append('session_id', S.session.session_id); formData.append('path', dir || '.'); @@ -1114,6 +1282,10 @@ async function _collectOsDropUploads(dataTransfer) { async function uploadOsDropToWorkspace(dataTransfer, destDir) { if (!S.session || !dataTransfer) return; + if(_workspacePathIsReadOnly(destDir || '.')){ + showToast(t('external_link_read_only'), 2000); + return; + } const uploads = await _collectOsDropUploads(dataTransfer); for (const { file, relDir } of uploads) { await uploadToWorkspace(file, _targetDirForRelDir(destDir, relDir)); @@ -1158,6 +1330,10 @@ function _bindWorkspaceOsUploadDropTarget(el, destDir) { e.preventDefault(); e.stopPropagation(); el.classList.remove('drag-over-upload'); + if(_workspacePathIsReadOnly(destDir || '.')){ + showToast(t('external_link_read_only'), 2000); + return; + } await uploadOsDropToWorkspace(e.dataTransfer, destDir); }); } @@ -1192,6 +1368,10 @@ if (typeof document !== 'undefined') { if (e.target.closest('.file-item[data-ws-type="dir"],.file-item[data-ws-is-dir="true"],.breadcrumb-seg')) return; e.preventDefault(); e.stopPropagation(); + if(_workspacePathIsReadOnly(S.currentDir || '.')){ + showToast(t('external_link_read_only'), 2000); + return; + } await uploadOsDropToWorkspace(e.dataTransfer, S.currentDir || '.'); }); }; diff --git a/tests/test_issue4582_workspace_escape_navigation.py b/tests/test_issue4582_workspace_escape_navigation.py new file mode 100644 index 00000000..d326a7b8 --- /dev/null +++ b/tests/test_issue4582_workspace_escape_navigation.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +import pathlib +import urllib.error +import urllib.request + +from api.routes import _project_os_workspace_read +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" + + +def _get_json(path: str) -> dict: + with urllib.request.urlopen(BASE + path, timeout=10) as response: + return json.loads(response.read()) + + +def _get_bytes(path: str) -> bytes: + with urllib.request.urlopen(BASE + path, timeout=10) as response: + return response.read() + + +def _post_json(path: str, body: dict | None = None) -> tuple[dict, int]: + req = urllib.request.Request( + BASE + path, + data=json.dumps(body or {}).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read()), response.status + except urllib.error.HTTPError as exc: + return json.loads(exc.read()), exc.code + + +def _make_session(workspace: pathlib.Path) -> str: + _post_json("/api/workspaces/add", {"path": str(workspace)}) + payload, status = _post_json("/api/session/new", {"workspace": str(workspace)}) + assert status == 200, payload + return payload["session"]["session_id"] + + +class TestIssue4582EscapeNavigationLive: + def test_authorized_dir_list_read_and_raw_stay_virtualized(self, tmp_path): + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + workspace.mkdir() + outside.mkdir() + (outside / "note.txt").write_text("outside note", encoding="utf-8") + (workspace / "escape").symlink_to(outside) + + sid = _make_session(workspace) + root_listing = _get_json(f"/api/list?session_id={sid}&path=.") + escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape"] + assert escape_row["target_outside_workspace"] is True + + auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"}) + assert status == 200, auth + assert auth["path"] == "escape" + assert auth["is_dir"] is True + assert auth["read_only"] is True + + listed = _get_json( + f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape" + ) + entries = {entry["name"]: entry for entry in listed["entries"]} + assert listed["path"] == "escape" + assert listed["read_only"] is True + assert entries["note.txt"]["path"] == "escape/note.txt" + assert entries["note.txt"]["escape_read_only"] is True + assert str(outside) not in json.dumps(listed) + + text = _get_json( + f"/api/escape/file/read?session_id={sid}&token={auth['token']}&path=escape/note.txt" + ) + assert text["path"] == "escape/note.txt" + assert text["content"] == "outside note" + assert text["escape_read_only"] is True + + raw = _get_bytes( + f"/api/escape/file/raw?session_id={sid}&token={auth['token']}&path=escape/note.txt" + ) + assert raw == b"outside note" + assert _project_os_workspace_read(pathlib.Path(workspace), "escape/note.txt") is None + + def test_nested_escape_row_stays_display_only_and_non_browsable(self, tmp_path): + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + second_outside = tmp_path / "second-outside" + workspace.mkdir() + outside.mkdir() + second_outside.mkdir() + (second_outside / "secret.txt").write_text("secret", encoding="utf-8") + (outside / "nested-escape").symlink_to(second_outside) + (workspace / "escape").symlink_to(outside) + + sid = _make_session(workspace) + auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"}) + assert status == 200, auth + + listed = _get_json( + f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape" + ) + entries = {entry["name"]: entry for entry in listed["entries"]} + nested = entries["nested-escape"] + assert nested["target_outside_workspace"] is True + assert nested["escape_read_only"] is True + assert "target" not in nested + + try: + _get_json( + f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape/nested-escape" + ) + assert False, "nested escape traversal should stay blocked" + except urllib.error.HTTPError as exc: + assert exc.code in (403, 404) + + +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_external_rows_authorize_then_open(self): + src = UI_JS.read_text(encoding="utf-8") + assert "authorizeWorkspaceEscapeNavigation(item)" in src + assert "if(grant.isDir) await loadDir(item.path);" in src + assert "else await openFile(item.path);" in src + + 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") + assert "if(!isReadOnlyEscape){" in ui_src + assert "_workspacePathIsReadOnly(_previewCurrentPath)" in ws_src + assert "_workspacePathIsReadOnly(S.currentDir || '.')" in ws_src diff --git a/tests/test_routes_file_api_toctou.py b/tests/test_routes_file_api_toctou.py index cd73504e..3ce236c3 100644 --- a/tests/test_routes_file_api_toctou.py +++ b/tests/test_routes_file_api_toctou.py @@ -141,6 +141,17 @@ def test_raw_and_inline_file_targets_carry_anchor_root(): assert "_serve_file_bytes(handler, target, mime, disposition, \"no-store\", csp=csp, anchor_root=anchor_root)" in raw_handler +def test_escape_raw_and_read_routes_use_authorized_helpers(): + src = ROUTES_PY.read_text(encoding="utf-8") + escape_read = _func_body(src, "_handle_escape_file_read") + escape_raw = _func_body(src, "_handle_escape_file_raw") + + assert "read_authorized_escape_file_content" in escape_read + assert "raw_authorized_escape_target" in escape_raw + assert "_serve_inline_html_preview(handler, target, \"no-store\", csp=sandbox_csp, anchor_root=anchor_root)" in escape_raw + assert "_serve_file_bytes(handler, target, mime, disposition, \"no-store\", csp=csp, anchor_root=anchor_root)" in escape_raw + + def test_upload_archive_cleanup_uses_anchored_helpers(): src = UPLOAD_PY.read_text(encoding="utf-8") diff --git a/tests/test_symlink_cycle_detection.py b/tests/test_symlink_cycle_detection.py index a0939e1a..e1682cef 100644 --- a/tests/test_symlink_cycle_detection.py +++ b/tests/test_symlink_cycle_detection.py @@ -18,8 +18,10 @@ import pathlib import urllib.request import urllib.error import tempfile +import pytest from tests._pytest_port import BASE +import api.workspace as w def get(path): @@ -79,6 +81,8 @@ class TestSymlinkCycleDetection: def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory): """Internal symlink dirs should appear with type='symlink', is_dir=True.""" + if not w._DIR_FD_OK: + pytest.skip("internal symlink listing is platform-dependent without dir_fd") ws = tmp_path_factory.mktemp("ws") target = ws / "target" target.mkdir() @@ -109,8 +113,27 @@ class TestSymlinkCycleDetection: except urllib.error.HTTPError as e: assert e.code in (400, 404, 500) + def test_escape_authorized_listing_virtualizes_paths(self, cleanup_test_sessions, tmp_path_factory): + ws = tmp_path_factory.mktemp("ws") + target = tmp_path_factory.mktemp("target") + (target / "child.txt").write_text("data", encoding="utf-8") + (ws / "ext").symlink_to(target) + + sid, _ = make_session(cleanup_test_sessions, ws) + auth, status = post("/api/escape/authorize", {"session_id": sid, "path": "ext"}) + assert status == 200, auth + listed = get(f"/api/escape/list?session_id={sid}&token={auth['token']}&path=ext") + entries = {entry["name"]: entry for entry in listed["entries"]} + assert listed["path"] == "ext" + assert listed["read_only"] is True + assert entries["child.txt"]["path"] == "ext/child.txt" + assert entries["child.txt"]["escape_read_only"] is True + assert str(target) not in json.dumps(listed) + def test_self_referencing_symlink_filtered(self, cleanup_test_sessions, tmp_path_factory): """Symlink pointing to the workspace root itself must be filtered out.""" + if not w._DIR_FD_OK: + pytest.skip("cycle filtering is platform-dependent without dir_fd") ws = tmp_path_factory.mktemp("ws") (ws / "file.txt").write_text("data") (ws / "loop").symlink_to(ws) @@ -122,6 +145,8 @@ class TestSymlinkCycleDetection: def test_ancestor_symlink_filtered(self, cleanup_test_sessions, tmp_path_factory): """Symlink pointing to a parent of the workspace must be filtered out.""" + if not w._DIR_FD_OK: + pytest.skip("cycle filtering is platform-dependent without dir_fd") parent = tmp_path_factory.mktemp("parent") ws = parent / "workspace" ws.mkdir() @@ -171,6 +196,8 @@ class TestSymlinkCycleDetection: def test_internal_symlink_file_entry(self, cleanup_test_sessions, tmp_path_factory): """Internal symlink to a file should have is_dir=False and include size.""" + if not w._DIR_FD_OK: + pytest.skip("internal symlink metadata is platform-dependent without dir_fd") ws = tmp_path_factory.mktemp("ws") real = ws / "real" real.mkdir() diff --git a/tests/test_workspace_symlink_containment.py b/tests/test_workspace_symlink_containment.py index 2710db21..598c6ea7 100644 --- a/tests/test_workspace_symlink_containment.py +++ b/tests/test_workspace_symlink_containment.py @@ -1,6 +1,13 @@ import pytest -from api.workspace import list_dir, read_file_content, safe_resolve_ws +from api.workspace import ( + authorize_escape_target, + list_authorized_escape_dir, + list_dir, + read_file_content, + resolve_authorized_escape_request, + safe_resolve_ws, +) def test_safe_resolve_blocks_external_symlink_directory(tmp_path): @@ -69,6 +76,66 @@ def test_internal_symlink_still_resolves_within_workspace(tmp_path): assert "inside-link.txt" in {entry["name"] for entry in list_dir(workspace, ".")} +def test_authorized_escape_request_reanchors_descendants(tmp_path): + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + workspace.mkdir() + (outside / "nested").mkdir(parents=True) + (outside / "nested" / "inside.txt").write_text("inside", encoding="utf-8") + (workspace / "escape").symlink_to(outside) + + grant = authorize_escape_target(workspace, "sess-1", "escape") + resolved = resolve_authorized_escape_request( + workspace, + "sess-1", + grant["token"], + "escape/nested/inside.txt", + ) + + assert resolved["surface_path"] == "escape" + assert resolved["external_rel"] == "nested/inside.txt" + assert resolved["target"] == outside / "nested" / "inside.txt" + + +def test_authorized_escape_request_expires_when_surface_target_changes(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() + escape = workspace / "escape" + escape.symlink_to(outside_a) + + grant = authorize_escape_target(workspace, "sess-1", "escape") + + escape.unlink() + escape.symlink_to(outside_b) + + with pytest.raises(ValueError, match="expired"): + resolve_authorized_escape_request(workspace, "sess-1", grant["token"], "escape") + + +def test_authorized_listing_keeps_nested_child_escape_display_only(tmp_path): + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + second_outside = tmp_path / "second-outside" + workspace.mkdir() + outside.mkdir() + second_outside.mkdir() + (workspace / "escape").symlink_to(outside) + (outside / "nested-escape").symlink_to(second_outside) + + grant = authorize_escape_target(workspace, "sess-1", "escape") + payload = list_authorized_escape_dir(workspace, "sess-1", grant["token"], "escape") + entries = {entry["name"]: entry for entry in payload["entries"]} + + assert entries["nested-escape"]["path"] == "escape/nested-escape" + assert entries["nested-escape"]["target_outside_workspace"] is True + assert entries["nested-escape"]["escape_read_only"] is True + assert "target" not in entries["nested-escape"] + + # ── TOCTOU hardening (#3398): a path that passes safe_resolve_ws() but is then # swapped to an external symlink before the open must not read/list/write # outside the workspace. The read/list/write paths use a portable anchored