harden(#4757): gate stripped-MIME drop on text/plain===tracked path + robust drag-flag cleanup (gate finding)

Codex gate found a SILENT hazard: if a workspace drag is abandoned without
ondragend firing (cancel/drop-outside/blur), _wsActiveDragPath lingered and a
later FOREIGN text/plain drag could be misread as a workspace move. Hardening:
- _wsDragSrcPath now only trusts the active flag when the drop's own text/plain
  EQUALS _wsActiveDragPath (a foreign/empty text drag resolves to '' -> no move).
- Shared _clearWsDragData() called from ondragend, a drop finally, and
  window-level dragend/drop/pagehide/blur fallbacks (lost-dragend cleanup).
- 2 regression tests (stale-flag foreign-text -> empty; _clearWsDragData resets),
  proven non-vacuous.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermesandRod Boev committed 2026-06-24 19:06:30 +00:00
1 parent 30c9f5e52a
commit 63e3f91654
2 files changed
+71 -7

No files matched your search

+34 -7
View File
@@ -14772,16 +14772,39 @@ function _setWsDragData(e,item){
_wsActiveDragPath=item.path;
_wsActiveDragType=item.type;
}
function _clearWsDragData(){
_wsActiveDragPath=null;
_wsActiveDragType=null;
}
// Window-level fallback cleanup: if a workspace drag is abandoned without the
// row's ondragend firing (drag cancelled, dropped outside any target, tab
// blurred/hidden mid-drag), the active-drag flag must not survive — otherwise a
// later FOREIGN text/plain drag could be misread as a workspace move.
if(typeof window!=='undefined'&&!window._wsDragCleanupBound){
window._wsDragCleanupBound=true;
window.addEventListener('dragend',_clearWsDragData,true);
window.addEventListener('drop',_clearWsDragData,true);
window.addEventListener('pagehide',_clearWsDragData);
window.addEventListener('blur',_clearWsDragData);
}
function _isWorkspaceTreeMoveDrag(e){
if(e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('Files')) return false;
if(e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('application/ws-path')) return true;
// Stripped-MIME (macOS WebKit) fallback: accept text/plain ONLY while a
// workspace drag is genuinely in flight. dragover/drop events can't read the
// payload, so gate on the active flag alone here; the drop handler additionally
// proves text/plain === _wsActiveDragPath before performing the move.
return !!(_wsActiveDragPath&&e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('text/plain'));
}
function _wsDragSrcPath(e){
const custom=e.dataTransfer.getData('application/ws-path');
if(custom) return custom;
if(_wsActiveDragPath) return _wsActiveDragPath;
return e.dataTransfer.getData('text/plain')||'';
// Stripped-MIME fallback: only trust the active flag when the drop's own
// text/plain matches it. A foreign text/plain drag (different/empty content)
// must NOT resolve to our tracked workspace path even if the flag lingered.
const plain=e.dataTransfer.getData('text/plain')||'';
if(_wsActiveDragPath&&plain===_wsActiveDragPath) return _wsActiveDragPath;
return '';
}
function _wsDragSrcType(e){
const custom=e.dataTransfer.getData('application/ws-type');
@@ -14872,10 +14895,14 @@ function _bindWorkspaceMoveDropTarget(el,destDir){
if(!_isWorkspaceTreeMoveDrag(e))return;
e.preventDefault();e.stopPropagation();
el.classList.remove('drag-over');
const srcPath=_wsDragSrcPath(e);
if(!srcPath)return;
const srcType=_wsDragSrcType(e);
await _performWorkspaceMove(srcPath,destDir,srcType==='dir');
try{
const srcPath=_wsDragSrcPath(e);
if(!srcPath)return;
const srcType=_wsDragSrcType(e);
await _performWorkspaceMove(srcPath,destDir,srcType==='dir');
}finally{
_clearWsDragData();
}
};
}
@@ -14898,7 +14925,7 @@ function _renderTreeItems(container, entries, depth){
e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);
};
el.ondragstart=(e)=>{_setWsDragData(e,item);e.dataTransfer.effectAllowed='copy';el.classList.add('dragging');};
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();_wsActiveDragPath=null;_wsActiveDragType=null;};
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();_clearWsDragData();};
const isLk = item.type === 'symlink';
const isExternalLink = isLk && item.target_outside_workspace;
@@ -51,6 +51,7 @@ def _extract_drag_functions():
extract_function(name)
for name in (
'_setWsDragData',
'_clearWsDragData',
'_isWorkspaceTreeMoveDrag',
'_wsDragSrcPath',
'_wsDragSrcType',
@@ -131,6 +132,42 @@ process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
assert _run_node(js) == 'false', \
'foreign text drag must be rejected when no active-drag flag is set'
def test_stale_flag_foreign_text_resolves_empty(self):
"""If the active-drag flag lingers (lost dragend) and a FOREIGN text/plain
drag arrives whose content differs from the tracked path, _wsDragSrcPath
must return '' (not the stale path) so no spurious workspace move runs.
Regression guard for the gate-found SILENT hazard: the stripped-MIME
fallback now requires text/plain === _wsActiveDragPath."""
js = """\
const item = {path: 'docs/readme.md', type: 'file'};
const startDt = new FakeDataTransfer([], {});
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
_setWsDragData({dataTransfer: startDt}, item);
// Flag still set (dragend never fired). A foreign text drag arrives with
// DIFFERENT content and no custom MIME.
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': 'some pasted text'});
dropDt.getData = function(mime){ if(mime==='application/ws-path') return ''; return this._map[mime]||''; };
process.stdout.write('[' + _wsDragSrcPath({dataTransfer: dropDt}) + ']');
"""
assert _run_node(js) == '[]', \
'stale flag + foreign text (content != tracked path) must resolve to empty'
def test_clear_ws_drag_data_resets_flags(self):
"""_clearWsDragData nulls both flags so a later text/plain-only drag is rejected."""
js = """\
const item = {path: 'tmp.txt', type: 'file'};
const startDt = new FakeDataTransfer([], {});
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
_setWsDragData({dataTransfer: startDt}, item);
_clearWsDragData();
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path});
process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
"""
assert _run_node(js) == 'false', \
'_clearWsDragData must reset flags so a stripped-MIME drag is no longer accepted'
def test_files_drag_rejected_even_with_active_flag(self):
"""Files drop must be rejected regardless of active-drag state."""
js = """\