From 026d11f517da17bfd3c35ea502057babdfbe5828 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 23 Aug 2026 07:34:35 +0530 Subject: [PATCH] Drag and drop protocol: deny requests for drag data made before the user has actually dropped something onto the window --- docs/changelog.rst | 2 ++ docs/dnd-protocol.rst | 9 ++++++ kitty/dnd.c | 33 ++++++++++++++++++++++ kitty_tests/dnd.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 84da4c9af..9ae9cc7bc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/dnd-protocol.rst b/docs/dnd-protocol.rst index 9268bd515..97ce17888 100644 --- a/docs/dnd-protocol.rst +++ b/docs/dnd-protocol.rst @@ -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 diff --git a/kitty/dnd.c b/kitty/dnd.c index d7fc17875..aeb03acdc 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -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) { diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index 8cd313f92..d6d19b83c 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -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):