Drag and drop protocol: deny requests for drag data made before the user has actually dropped something onto the window

This commit is contained in:
Kovid Goyal 2026-08-23 07:34:35 +05:30
parent 9475a58f0b
commit 026d11f517
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
4 changed files with 108 additions and 0 deletions

View file

@ -251,6 +251,8 @@ Detailed list of changes
- IME preedit text: use underline rather than reverse video (:pull:`10386`)
- Drag and drop protocol: deny requests for drag data made before the user has actually dropped something onto the window
0.48.2 [2026-07-30]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -104,6 +104,15 @@ mandatory, terminals must send the full list of MIME types available in
the drop. The client program can now request data for the MIME types
it is interested in.
Data may only be requested after a drop has actually occurred, receiving
movement events is not sufficient. Terminals must reject any data request
received before the drop event with an ``EPERM`` error. Since no drop is in
progress, this particular error does not terminate anything. Additionally,
when a drag leaves the window without a drop having occurred, terminals must
discard all data and resources associated with the drag session, such as
previously fetched URI lists, in-progress file transfers and directory
handles. Requests referring to them must fail.
Requesting data is done by sending an escape code of the form::
OSC _dnd_code ; t=r:x=idx ST

View file

@ -1367,6 +1367,23 @@ drop_enqueue_request(Window *w, int32_t cell_x, int32_t cell_y, int32_t pixel_y,
return;
}
if (!w->drop.dropped) {
/* The user has not actually dropped anything on this window, so the
* client is not allowed to read any drag data. Movement events are
* informational only; consent to transfer data is given by the drop. */
int32_t saved_x = w->drop.current_request_x;
int32_t saved_y = w->drop.current_request_y;
int32_t saved_Y = w->drop.current_request_Y;
w->drop.current_request_x = cell_x;
w->drop.current_request_y = cell_y;
w->drop.current_request_Y = pixel_y;
drop_send_error(w, EPERM, "drop data can only be requested after a drop");
w->drop.current_request_x = saved_x;
w->drop.current_request_y = saved_y;
w->drop.current_request_Y = saved_Y;
return;
}
if (w->drop.num_data_requests >= arraysz(w->drop.data_requests)) {
/* Queue full: deny with EMFILE and end the drop */
int32_t saved_x = w->drop.current_request_x;
@ -1397,6 +1414,18 @@ drop_left_child(Window *w) {
w->drop.hovered = false;
w->drop.dropped = false;
drop_free_offered_mimes(w);
/* The drag session no longer involves this window, so discard everything
* obtained from it: otherwise a previously fetched URI list, open
* directory handles and an in-flight file transfer would let the client
* keep reading files with no drag in progress. */
drop_close_file_fd(w);
drop_free_request_queue(w);
drop_free_dir_handles(w);
free(w->drop.uri_list);
w->drop.uri_list = NULL;
w->drop.uri_list_sz = 0;
free(w->drop.getting_data_for_mime);
w->drop.getting_data_for_mime = NULL;
if (w->drop.wanted) {
char buf[128];
int header_size = snprintf(buf, sizeof(buf), "\x1b]%d;t=m:x=-1:y=-1", DND_CODE);
@ -2893,6 +2922,10 @@ dnd_test_probe_state(PyObject *self UNUSED, PyObject *args) {
return ans;
}
if (strcmp(q, "drop_getting_data_for_mime") == 0) { return PyUnicode_FromString(w->drop.getting_data_for_mime ? w->drop.getting_data_for_mime : ""); }
if (strcmp(q, "drop_dropped") == 0) { return Py_NewRef(w->drop.dropped ? Py_True : Py_False); }
if (strcmp(q, "drop_num_dir_handles") == 0) { return PyLong_FromSize_t(w->drop.num_dir_handles); }
if (strcmp(q, "drop_uri_list_sz") == 0) { return PyLong_FromSize_t(w->drop.uri_list_sz); }
if (strcmp(q, "drop_file_fd_plus_one") == 0) { return PyLong_FromLong((long)w->drop.file_fd_plus_one); }
if (strcmp(q, "can_offer") == 0) { return Py_NewRef(w->drag_source.can_offer ? Py_True : Py_False); }
if (strcmp(q, "drag_operations") == 0) { return PyLong_FromLong((long)w->drag_source.allowed_operations); }
if (strcmp(q, "drag_mimes") == 0) {

View file

@ -696,6 +696,70 @@ class TestDnDProtocol(BaseTest):
self.assertEqual(len(r_events), 1, raw)
self.ae(r_events[0]['payload'], b'')
def test_data_request_before_drop_denied(self) -> None:
"""Data requests sent while the drag is merely hovering are rejected with EPERM."""
with dnd_test_window() as (screen, cap):
self._register_for_drops(screen, cap, 'text/plain text/uri-list')
dnd_test_set_mouse_pos(cap.window_id, 0, 0, 0, 0)
# Only a move event so far, no drop.
dnd_test_fake_drop_event(cap.window_id, False, ['text/plain', 'text/uri-list'])
cap.consume()
# All three request forms must be denied before the drop.
for req in (client_request_data(1), client_request_uri_data(2, 1), client_dir_read(2, 1)):
parse_bytes(screen, req)
events = self._get_events(cap)
self.assertEqual(len(events), 1, events)
self.ae(events[0]['type'], 'R')
self.ae(events[0]['payload'].strip().partition(b':')[0], b'EPERM')
# The denial must not have terminated the drag session: after an
# actual drop, requests work as usual.
dnd_test_fake_drop_event(cap.window_id, True, ['text/plain', 'text/uri-list'])
cap.consume()
parse_bytes(screen, client_request_data(1))
dnd_test_fake_drop_data(cap.window_id, 'text/plain', b'hello')
combined = b''.join(e['payload'] for e in parse_escape_codes_b64(cap.consume()) if e['type'] == 'r')
self.ae(combined, b'hello')
def test_drag_leave_discards_drag_session_data(self) -> None:
"""When the drag leaves the window all data obtained from it is discarded."""
import os
import tempfile
with tempfile.TemporaryDirectory() as root:
with open(os.path.join(root, 'secret.txt'), 'wb') as f:
f.write(b'secret data')
uri_list = f'file://{root}\r\n'.encode()
with dnd_test_window() as (screen, cap):
self._setup_uri_drop(screen, cap, uri_list)
# Get a directory handle for root.
parse_bytes(screen, client_request_uri_data(2, 1))
events = parse_escape_codes_b64(cap.consume())
d_events = [e for e in events if e['type'] == 'r' and is_dir_event(e)]
self.assertTrue(d_events, 'expected directory listing for root')
handle_id = dir_handle(d_events[0])
self.assertGreater(dnd_test_probe_state(cap.window_id, 'drop_num_dir_handles'), 0)
self.assertGreater(dnd_test_probe_state(cap.window_id, 'drop_uri_list_sz'), 0)
# Drag leaves the window.
dnd_test_fake_drop_event(cap.window_id, False, None)
cap.consume() # discard the leave event
# The URI list, directory handles and any in-flight file
# transfer must be gone.
self.ae(dnd_test_probe_state(cap.window_id, 'drop_num_dir_handles'), 0)
self.ae(dnd_test_probe_state(cap.window_id, 'drop_uri_list_sz'), 0)
self.ae(dnd_test_probe_state(cap.window_id, 'drop_file_fd_plus_one'), 0)
self.assertFalse(dnd_test_probe_state(cap.window_id, 'drop_dropped'))
# Requests using the stale handle must fail.
parse_bytes(screen, client_dir_read(handle_id, 1))
events = self._get_events(cap)
self.assertEqual(len(events), 1, events)
self.ae(events[0]['type'], 'R')
self.ae(events[0]['payload'].strip().partition(b':')[0], b'EPERM')
# ---- remote file/directory transfer tests ----------------
def _setup_uri_drop(self, screen, cap, uri_list_data: bytes, mimes=None):