Skip to content

Sanitize and bound terminal paste, keep the provider paste entry point unrouted, and protect the clipboard on a failed copy - #684

Merged
coneilen merged 3 commits into
mainfrom
coneilen-terminal-paste-safety
Oct 10, 2026
Merged

coneilen merged 3 commits into
mainfrom
coneilen-terminal-paste-safety

Conversation

@coneilen

@coneilen coneilen commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to the review of #676 and #679 on terminal paste and copy. Three defects, verified in code before changing it:

  1. TerminalSurface.zig registered the provider's on_paste callback and enqueued its text unchanged, ignoring bracketed. The pinned provider's winghostty_surface_paste_text always brackets (its bracketed_paste option 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 inject ESC [ 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.pasteText is the only route the shell uses), so this is a hole that is reachable only by a future caller, not a measured exploit.
  2. No size limit existed before the UTF-8 allocation: Clipboard.readText decoded any clipboard size and encodePaste allocated a normalized copy before the 1 MiB input-queue check refused it.
  3. Clipboard.writeText called EmptyClipboard and then, if SetClipboardData refused the text, left the user's previous clipboard empty and reported only a generic copy failure.

Changes

  • Removed the on_paste registration 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, the win32_host.zig that the shell links): emitPaste is called only from the exported winghostty_surface_paste_text; that file has no WM_PASTE, DragAcceptFiles/RegisterDragDrop, middle-click paste or context-menu paste, and GetClipboardData appears only in the host's own winghostty_surface_read_clipboard path, whose callback the shell ignores. The shell never calls winghostty_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 through Workspace.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 in encodePaste (new TerminalPasteTooLarge). Clipboard.readText enforces 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.writeText copies 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 is unreadable, not none) and, if that text is unreadable, refuses the copy with ClipboardPreviousTextUnreadable before 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 reports ClipboardWriteFailedKeptPreviousText or ClipboardWriteFailedLostPreviousText; 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.encodePaste blanks the C1 controls U+0080 to U+009F, both as UTF-8 (C2 80 to C2 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.
  • encodePaste comments 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.zig every paste is normalized and stripped of control bytes, with or without bracketed paste (14 cases): exact bytes queued by Workspace.pasteText for 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 and ESC [ 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 gives TerminalPasteTooLarge with nothing queued, encodePaste with an allocator that fails every allocation still returns TerminalPasteTooLarge (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 for C2 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 (a ClipboardTextTooLarge read 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 where winghostty_surface_paste_text is 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

  • RED against the unfixed code, behavioral: the live provider-paste test (the unfixed shell enqueued ESC [ 200 ~ plain ESC [ 201 ~ for even a plain string), and the size-limit test (1,000,001 bytes was queued, no error).
  • A third RED test, that surfaceOptions leaves on_paste null, failed against the unfixed code and was then deleted: TerminalSurface.zig's own test root does not link the provider library, so it cannot call surfaceOptions. The live provider test is what covers the removal.
  • The 14-case normalization test also failed on its first run, because one of my expectations was wrong (BEL is not in the encoder's strip list); I corrected the expectation to match the documented xterm list. Apart from that, its cases and the confirmation test are guards for behavior that already held, not RED.
  • RED for review round 2: the C1 test failed against the previous commit (U+009B passed through unchanged: expected x 201~y, found the 0xC2 0x9B bytes). Not RED: the Clipboard tests (including the unreadable and unterminated cases), the status-wording tests and the live copy-failure test use APIs that did not exist or changed shape, so against the old code they fail only to compile; that is not behavioral evidence.

What is NOT tested

  • The native clipboard path: OpenClipboard, EmptyClipboard, SetClipboardData, GetClipboardData in NativeApi and readText never ran, and no test touched the real clipboard. The restore-on-failure logic is exercised only against the stand-in; whether Windows refuses SetClipboardData in the ways the stand-in models is an assumption.
  • No conhost, pwsh, ConPTY, zmx or Dev Box; no real paste from a real program, no physical keyboard. Bytes are read from the shell's input queue.
  • Only the control bytes in xterm's strip list are blanked; BEL, VT, FF and other C0 bytes still reach the program. Bidirectional or other Unicode control characters are not filtered.
  • Non-Unicode clipboard formats are lost on any copy (see above). The limit logic is tested on in-memory blocks, not on a real 3 MB clipboard.
  • The 1,000,000-byte limit is a judgment call, not measured; the shell's existing input queue already refused anything above 1 MiB.
  • Isolation: process-only scratch USERPROFILE, APPDATA, LOCALAPPDATA, TEMP, TMP and GRAPHCODE_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

  • I have read the Contributing Guidelines
  • I have signed off my commits (git commit -s) per the DCO
  • Tests pass locally (make test) - macOS target, not run: Windows-only change; the Windows roots above were run instead
  • Code follows the existing style (make check) - macOS lint target, not run: Windows-only change in Zig
  • I added the test/contract before the implementation and observed the intended RED failure

@coneilen coneilen changed the title Route every terminal paste through one pipeline, bound its size, and keep the clipboard on a failed copy Sanitize and bound terminal paste, keep the provider paste entry point unrouted, and protect the clipboard on a failed copy Oct 10, 2026
@coneilen
coneilen force-pushed the coneilen-terminal-paste-safety branch from d4ca5c2 to 56d1cc9 Compare October 10, 2026 06:56
coneilen and others added 3 commits October 10, 2026 00:36
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
coneilen force-pushed the coneilen-terminal-paste-safety branch from 392fee5 to dbc1d44 Compare October 10, 2026 07:39
@coneilen
coneilen merged commit 3fb2167 into main Oct 10, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant