harden(#4581): display-only escape symlinks must not disclose target path/metadata
Codex+Opus gate (info-leak): the display-only escape-target rows still returned the resolved
outside path (target), target-derived is_dir, and target stat size; ui.js showed the outside path
in the open-dialog. Fix: emit ONLY name/path/type/target_outside_workspace/mtime for outside rows
(no target, no size, is_dir=False); dialog uses a generic 'points outside workspace' message (dropped
{target} from external_link_open_confirm across all 13 locales). Containment was already intact (both
gates confirmed); this closes the path-disclosure. Tests updated to assert the no-leak property.
This commit is contained in:
parent
1e18c336f1
commit
077de5455c
5 changed files with 63 additions and 35 deletions
|
|
@ -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 != '.':
|
||||
|
|
|
|||
|
|
@ -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ę: ',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue