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:
nesquena-hermes 2026-06-21 09:11:57 +00:00 committed by nesquena-hermes
parent 1e18c336f1
commit 077de5455c
5 changed files with 63 additions and 35 deletions

View file

@ -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 != '.':

View file

@ -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ę: ',

View file

@ -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,

View file

@ -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."""

View file

@ -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):