diff --git a/api/workspace.py b/api/workspace.py index c5efe174..f736d25a 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -1232,26 +1232,42 @@ def list_dir(workspace: Path, rel: str='.'): target_outside_workspace = True if _is_blocked_system_path(link_target): return - is_dir = link_target.is_dir() display_path = name if rel and rel != '.': display_path = rel + '/' + display_path mtime_ns = lstat_result.st_mtime_ns if lstat_result is not None else None - entry = { - 'name': name, - 'path': display_path, - 'type': 'symlink', - 'target': str(link_target), - 'is_dir': is_dir, - 'target_outside_workspace': target_outside_workspace, - 'mtime_ns': mtime_ns, - } - if not is_dir: - try: - entry['size'] = link_target.stat().st_size - except OSError: - entry['size'] = None - entries.append(entry) + if target_outside_workspace: + # #4581 hardening: a display-only escape-target symlink must NOT + # disclose where it points. Emit ONLY display-safe fields — never + # the resolved outside path, target-derived is_dir, or target size + # (the row exists to show the link is present; navigation/read + # through it stays blocked by safe_resolve_ws/open_anchored_fd). + entry = { + 'name': name, + 'path': display_path, + 'type': 'symlink', + 'is_dir': False, + 'target_outside_workspace': True, + 'mtime_ns': mtime_ns, + } + entries.append(entry) + else: + is_dir = link_target.is_dir() + entry = { + 'name': name, + 'path': display_path, + 'type': 'symlink', + 'target': str(link_target), + 'is_dir': is_dir, + 'target_outside_workspace': False, + 'mtime_ns': mtime_ns, + } + if not is_dir: + try: + entry['size'] = link_target.stat().st_size + except OSError: + entry['size'] = None + entries.append(entry) else: entry_path = name if rel and rel != '.': diff --git a/static/i18n.js b/static/i18n.js index b79d7b01..ec0df00f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -443,7 +443,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', @@ -1989,7 +1989,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Rinominato in ', rename_failed: 'Rinomina fallita: ', @@ -3526,7 +3526,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。', renamed_to: '名前を変更: ', rename_failed: '名前変更失敗: ', @@ -4987,7 +4987,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Переименовано в ', rename_failed: 'Не удалось переименовать: ', @@ -6441,7 +6441,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renombrado a ', rename_failed: 'Error al renombrar: ', @@ -7893,7 +7893,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Umbenannt in ', rename_failed: 'Umbenennen fehlgeschlagen: ', @@ -9410,7 +9410,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: '已重命名为 ', rename_failed: '重命名失败:', @@ -10961,7 +10961,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: '無法開始重新命名,找不到資料列。', renamed_to: '已重新命名為 ', rename_failed: '重新命名失敗:', @@ -12401,7 +12401,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renomeado para ', rename_failed: 'Falha ao renomear: ', @@ -13832,7 +13832,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renamed to ', rename_failed: 'Rename failed: ', @@ -15308,7 +15308,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', session_rename_failed_no_row: 'Could not start rename — row not found.', renamed_to: 'Renommé en', rename_failed: 'Échec du changement de nom :', @@ -16850,7 +16850,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', 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:', @@ -18401,7 +18401,7 @@ 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. Target: {target}', + external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.', 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 292c74a5..c4a64f9e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14507,13 +14507,14 @@ function _renderTreeItems(container, entries, depth){ } }; }else if(isExternalLink){ - // Display-only: show the resolved target, do NOT call openFile. - // The read gate (safe_resolve_ws) blocks navigation through the link. + // 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. el.onclick=async(e)=>{ e.stopPropagation(); await showConfirmDialog({ title:item.name, - message:t('external_link_open_confirm').replace('{target}',()=>elideMiddle(item.target||'')), + message:t('external_link_open_confirm'), confirmLabel:t('dialog_confirm_btn'), danger:false, hideCancel:true, diff --git a/tests/test_symlink_cycle_detection.py b/tests/test_symlink_cycle_detection.py index 95fa566e..a0939e1a 100644 --- a/tests/test_symlink_cycle_detection.py +++ b/tests/test_symlink_cycle_detection.py @@ -72,7 +72,10 @@ class TestSymlinkCycleDetection: assert "ext" in entries assert entries["ext"]["type"] == "symlink" assert entries["ext"]["target_outside_workspace"] is True - assert entries["ext"]["is_dir"] is True + # #4581 hardening: display-only escape rows don't disclose target-derived + # metadata (is_dir/target), so an external dir symlink reports is_dir=False. + assert entries["ext"]["is_dir"] is False + assert "target" not in entries["ext"] 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.""" diff --git a/tests/test_workspace_symlink_containment.py b/tests/test_workspace_symlink_containment.py index 911eda58..2710db21 100644 --- a/tests/test_workspace_symlink_containment.py +++ b/tests/test_workspace_symlink_containment.py @@ -23,7 +23,11 @@ def test_safe_resolve_blocks_external_symlink_directory(tmp_path): assert "escape" in entries assert entries["escape"]["type"] == "symlink" assert entries["escape"]["target_outside_workspace"] is True - assert entries["escape"]["is_dir"] is True + # #4581 hardening: display-only escape rows are uniformly is_dir=False — the + # target's real dir/file nature is target-derived metadata we don't disclose + # (the row is non-navigable regardless). + assert entries["escape"]["is_dir"] is False + assert "target" not in entries["escape"] def test_read_file_blocks_external_symlink_file(tmp_path): @@ -335,7 +339,11 @@ def test_list_dir_outside_workspace_symlink_emitted_with_flag(tmp_path): assert entries["ext-link.txt"]["type"] == "symlink" assert entries["ext-link.txt"]["target_outside_workspace"] is True assert entries["ext-link.txt"]["is_dir"] is False - assert entries["ext-link.txt"]["target"] == str((outside / "file.txt").resolve()) + # #4581 hardening: a display-only escape-target row must NOT disclose where it + # points — no resolved outside path, no target-derived size, no target-derived + # metadata. Only the link name/path + the display-only flag are emitted. + assert "target" not in entries["ext-link.txt"] + assert "size" not in entries["ext-link.txt"] def test_list_dir_external_symlink_blocked_system_path_unchanged(tmp_path):