Repository navigation
Sanitize and bound terminal paste, keep the provider paste entry point unrouted, and protect the clipboard on a failed copy - #684
Merged
Conversation
coneilen
force-pushed
the
coneilen-terminal-paste-safety
branch
from
October 10, 2026 06:56
d4ca5c2 to
56d1cc9
Compare
The shell registered the provider's on_paste callback and enqueued its text unchanged. winghostty_surface_paste_text always brackets the payload and does not sanitize it, so a caller of that entry point could put ESC [ 201 ~ and other control bytes on the pty whatever the program asked for. Nothing in the shell calls it today, so remove the callback: every paste now goes through Workspace.pasteText, which follows the program's bracketed-paste mode, normalizes line endings, blanks the control bytes xterm blanks and asks for confirmation before running lines. Bound the paste before allocating for it: text over 1,000,000 bytes is refused in encodePaste, and Clipboard.readText refuses more UTF-16 units than that before decoding. The status line says why. A Windows clipboard that refuses a copy after EmptyClipboard lost the user's previous text silently. Clipboard.writeText now reads the previous Unicode text first, puts it back if the write fails, and reports whether it did. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens <coneilen@microsoft.com>
Distinguish 'no Unicode text' from 'Unicode text that cannot be copied', and refuse the copy before emptying the clipboard in the second case. Snapshot only through the first terminator so a restore never republishes an unterminated block. Bound the paste limit in UTF-8 bytes by computing the converted length before allocating. Blank C1 controls (U+0080 to U+009F) in pasted text. Say in the provider paste comment why the callback stays unregistered. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens <coneilen@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens <coneilen@microsoft.com>
coneilen
force-pushed
the
coneilen-terminal-paste-safety
branch
from
October 10, 2026 07:39
392fee5 to
dbc1d44
Compare
This was referenced Oct 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to the review of #676 and #679 on terminal paste and copy. Three defects, verified in code before changing it:
TerminalSurface.zigregistered the provider'son_pastecallback and enqueued its text unchanged, ignoringbracketed. The pinned provider'swinghostty_surface_paste_textalways brackets (itsbracketed_pasteoption is fixed at 1), does not strip ESC or other control bytes, and hands that payload to the callback, so any caller of that entry point could injectESC [ 201 ~or control bytes regardless of the program's mode and without the multi-line confirmation. Nothing in the shell calls that entry point today (Workspace.pasteTextis the only route the shell uses), so this is a hole that is reachable only by a future caller, not a measured exploit.Clipboard.readTextdecoded any clipboard size andencodePasteallocated a normalized copy before the 1 MiB input-queue check refused it.Clipboard.writeTextcalledEmptyClipboardand then, ifSetClipboardDatarefused the text, left the user's previous clipboard empty and reported only a generic copy failure.Changes
on_pasteregistration and the callback, and kept it disabled instead of routing it, because the pinned provider never invokes it on any user path. Evidence, from the pinned provider checkout (winghostty 6286560, thewin32_host.zigthat the shell links):emitPasteis called only from the exportedwinghostty_surface_paste_text; that file has noWM_PASTE,DragAcceptFiles/RegisterDragDrop, middle-click paste or context-menu paste, andGetClipboardDataappears only in the host's ownwinghostty_surface_read_clipboardpath, whose callback the shell ignores. The shell never callswinghostty_surface_paste_text. So no provider-originated paste exists today to drop; every paste the shell itself performs (Ctrl+Shift+V, Shift+Insert, the context menu) goes throughWorkspace.pasteText(the program's DECSET 2004 decides bracketing, line endings are normalized, the control bytes xterm blanks and the C1 controls become spaces, and multi-line text for an unbracketed program needs confirmation). If a future caller adds provider-originated paste, it will need a deliberate route to that function; this PR does not provide one.TerminalVt.max_paste_bytes(1,000,000 UTF-8 bytes, a little under the 1 MiB input queue so bracket markers fit) is checked first inencodePaste(newTerminalPasteTooLarge).Clipboard.readTextenforces the same limit in UTF-8 bytes: more than that many UTF-16 units is refused outright (each unit is at least one byte), and otherwise the exact UTF-8 length is computed before the conversion allocates (ClipboardTextTooLarge), so a block of three-byte characters cannot allocate about 3 MB before refusal. The status line says "Paste cancelled: clipboard text is larger than 1,000,000 bytes".Clipboard.writeTextcopies the previous Unicode text before emptying the clipboard (a block with no terminator, a bad size, a lock or allocation failure while Unicode text is present isunreadable, notnone) and, if that text is unreadable, refuses the copy withClipboardPreviousTextUnreadablebefore touching the clipboard. The snapshot is bounded by the block size and stops at the first terminator, so a restore never republishes an unterminated or trailing-garbage block. If the new text is refused it writes the snapshot back and reportsClipboardWriteFailedKeptPreviousTextorClipboardWriteFailedLostPreviousText; the copy status says which. Only Unicode text is protected: emptying the clipboard removes every other format (image, HTML, RTF, files), which are not snapshotted or restored; a successful copy replaces them anyway.TerminalVt.encodePasteblanks the C1 controls U+0080 to U+009F, both as UTF-8 (C2 80toC2 9F) and as a lone byte 0x80 to 0x9F, because some terminals read U+009B as an 8-bit CSI. Characters whose UTF-8 merely contains such bytes (euro sign, é, emoji) are copied whole.encodePastecomments and the README/parity-matrix text no longer say "unsafe control bytes are blanked": the pinned encoder follows xterm's list (NUL, BS, ENQ, EOT, ESC, DEL, and the tty's interrupt, quit, kill, suspend, start, stop, word-erase, literal-next, reprint and discard characters). BEL and tab pass through, and a test pins that.What each test proves
TerminalSurface.zigevery paste is normalized and stripped of control bytes, with or without bracketed paste(14 cases): exact bytes queued byWorkspace.pasteTextfor CRLF, bare CR, bare LF, trailing LF and CRLF, NUL,ESC [ 201 ~inside the text, DEL/BS/Ctrl+C/Ctrl+Z, BEL and tab, with and without DECSET 2004.text that could close a bracket or run lines needs confirmation...: multi-line andESC [ 201 ~text is refused without confirmation for an unbracketed program and the markers cannot be closed early for a bracketed one.a paste above the documented size limit is refused...: 1,000,001 bytes givesTerminalPasteTooLargewith nothing queued,encodePastewith an allocator that fails every allocation still returnsTerminalPasteTooLarge(no allocation before the check), and exactly 1,000,000 bytes is accepted.Clipboard.zig(11 new tests, using a stand-in clipboard that follows the Win32 empty-before-write rule): a successful write replaces the text; a refused write restores the previous text and reports it; a refused write whose restore is also refused reports the loss; a refused write over a text-less clipboard is a plain failure; a clipboard that cannot be opened or emptied is left as it was; an existing Unicode item that cannot be snapshotted, and an existing unterminated block followed by a refused write, stop the copy before the clipboard is emptied (nothing changes, no write attempted); a block whose size is not a whole number of UTF-16 units (0, 1, 3, 5 bytes) is unreadable, and an existing 3-byte item stops the copy before the clipboard is emptied; a restore republishes only through the terminator; text over the limit is refused before decoding, a terminator before the limit ends the text, an unterminated block is reported; the limit counts UTF-8 bytes (two euro signs refused at 5 and accepted at 6, a surrogate pair refused at 3 and accepted at 4, refusal without allocating).TerminalVt.zig: one test pins C1 handling forC2 9B 201~and a lone 0x9B in bracketed and unbracketed modes, the other C1 introducers, and untouched multi-byte characters.App.zig: paste over the limit (aClipboardTextTooLargeread error and a 1,000,001-byte clipboard) shows the size status and sends nothing; the copy-failure status wording for each clipboard error; a live test with a real Winghostty surface wherewinghostty_surface_paste_textis called with plain, hostile (ESC [ 201 ~ ... CRLF) and multi-line text and nothing reaches the shell's input queue; a live test where a refusing clipboard stand-in keeps the mouse selection and shows the loss status.Which tests were RED
ESC [ 200 ~ plain ESC [ 201 ~for even a plain string), and the size-limit test (1,000,001 bytes was queued, no error).surfaceOptionsleaveson_pastenull, failed against the unfixed code and was then deleted:TerminalSurface.zig's own test root does not link the provider library, so it cannot callsurfaceOptions. The live provider test is what covers the removal.What is NOT tested
OpenClipboard,EmptyClipboard,SetClipboardData,GetClipboardDatainNativeApiandreadTextnever ran, and no test touched the real clipboard. The restore-on-failure logic is exercised only against the stand-in; whether Windows refusesSetClipboardDatain the ways the stand-in models is an assumption.USERPROFILE,APPDATA,LOCALAPPDATA,TEMP,TMPandGRAPHCODE_SUPPORT_DIR; no installed daemon, per-user zmx or real clipboard.Test plan
RED: zig test src\App.zig --test-filter "paste" (pinned target and link flags, unfixed code) -> 14 passed, 4 failed: provider paste reached the pty with 17 bytes ESC [ 200 ~ plain ESC [ 201 ~; expected error.TerminalPasteTooLarge, found void (the other two failures: the options test later deleted, and my wrong BEL expectation)
GREEN: zig test src\App.zig --test-filter "paste" (pinned flags, with the change) -> all paste tests pass; Clipboard root -> All 15 tests passed
REGRESSION: zig test for TerminalKeys, TerminalVt, TerminalKeyEncoding, TerminalSelection, Clipboard, TerminalSurface and App roots (pinned flags) -> 10, 23, 40, 36, 14, 237 and 1029 tests passed, 0 failed (run after rebasing on main with the F6/F10 change; earlier baseline before that rebase: Clipboard 4, TerminalSurface 223, App 1001); after the odd-size fix Clipboard 15 and App 1030
Checklist
git commit -s) per the DCOmake test) - macOS target, not run: Windows-only change; the Windows roots above were run insteadmake check) - macOS lint target, not run: Windows-only change in Zig