Lab 11: OSC, DEC Private Modes, Mouse Reporting, and Bracketed Paste
Background
The last lab of Section 2 implements the features that reach outside the grid: window titles, hyperlinks, clipboard access, and the modes that change how the terminal reports input. It is also where the security work happens — every feature in this lab is a channel through which untrusted bytes influence something beyond the screen.
Why This Lab Matters
- OSC 52 and OSC 8 are genuine attack surfaces. Terminals have shipped CVEs here.
- Mouse reporting and bracketed paste are the modes that make
htopclickable and make pasting intovimnot produce a staircase. - The modes implemented here are consumed by the input encoder in Milestone 8 — this is where the output path starts feeding the input path.
Prerequisites
- Lab 7 complete.
- Modes and OSC and String Sequences read.
Predict First
cata file containing an OSC 52 write sequence. Does your clipboard change? Should it?- A program sets
?1000and crashes. What does the user experience? - Bracketed paste is on and the pasted text contains
\x1b[201~. What must happen? ?1003(any-motion mouse) is on. How many events per second while moving the mouse?
Step 1: OSC Dispatch
#![allow(unused)] fn main() { fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) { let Some(cmd) = params.first().and_then(|p| std::str::from_utf8(p).ok()) .and_then(|s| s.parse::<u16>().ok()) else { return }; match cmd { 0 | 2 => self.set_title(params.get(1).copied().unwrap_or(b"")), 1 => self.set_icon_name(params.get(1).copied().unwrap_or(b"")), 4 => self.palette_command(¶ms[1..]), 7 => self.set_working_directory(params.get(1).copied().unwrap_or(b"")), 8 => self.hyperlink_command(¶ms[1..]), 10 | 11 | 12 => self.dynamic_color(cmd, params.get(1).copied().unwrap_or(b"")), 52 => self.clipboard_command(¶ms[1..]), 104 => self.reset_palette(¶ms[1..]), 110 | 111 | 112 => self.reset_dynamic_color(cmd), 133 => self.semantic_prompt(params.get(1).copied().unwrap_or(b"")), // Unknown OSC is ignored, never fatal. _ => self.debug_unhandled_osc(cmd, params), } } fn set_title(&mut self, raw: &[u8]) { const MAX_TITLE: usize = 1024; // Titles go to the window manager, the taskbar, and possibly a tmux status // line. Untrusted bytes must not carry control characters into any of those. self.title = String::from_utf8_lossy(&raw[..raw.len().min(MAX_TITLE * 4)]) .chars() .filter(|c| !c.is_control()) .take(MAX_TITLE) .collect(); self.title_changed = true; // the GUI polls this } }
Step 2: Hyperlinks, Interned
#![allow(unused)] fn main() { fn hyperlink_command(&mut self, params: &[&[u8]]) { // OSC 8 ; <params> ; <URI> ST // An EMPTY URI ends the current link. let uri = params.get(1).copied().unwrap_or(b""); if uri.is_empty() { self.cursor.style.hyperlink = 0; return; } const MAX_URI: usize = 2048; if uri.len() > MAX_URI { return; } let Ok(uri) = std::str::from_utf8(uri) else { return }; // The user cannot see the URI before clicking. A javascript: or data: URI // in a terminal hyperlink is a real attack, so allow-list the schemes. const SAFE: &[&str] = &["http://", "https://", "mailto:", "ftp://", "ftps://", "file://"]; if !SAFE.iter().any(|p| uri.starts_with(p)) { return; } // `id=` groups a link split across cells or lines, so hover highlights the // whole thing as one unit. let id = parse_link_id(params.first().copied().unwrap_or(b"")); self.cursor.style.hyperlink = self.hyperlinks.intern(id, uri); } }
Step 3: The Clipboard Policy
#![allow(unused)] fn main() { pub enum ClipboardPolicy { Deny, WriteOnly, Ask, Allow } fn clipboard_command(&mut self, params: &[&[u8]]) { let selection = params.first().copied().unwrap_or(b"c"); let data = params.get(1).copied().unwrap_or(b""); if data == b"?" { // READ. Denied unless explicitly allowed: a remote process could // exfiltrate whatever you last copied — passwords, tokens, anything. // xterm shipped this enabled once and got a CVE for it. if !matches!(self.clipboard_policy, ClipboardPolicy::Allow) { return; } self.pending_clipboard_read = Some(selection.to_vec()); return; } // WRITE. if matches!(self.clipboard_policy, ClipboardPolicy::Deny) { return; } const MAX_CLIPBOARD: usize = 100 * 1024; if data.len() > MAX_CLIPBOARD * 2 { return; } // base64 expands ~4/3 let Ok(decoded) = base64_decode_strict(data) else { return }; if decoded.len() > MAX_CLIPBOARD { return; } // The GUI drains this; terminal-core does no I/O and owns no clipboard. self.pending_clipboard_write = Some((selection.to_vec(), decoded)); } }
Warning:
base64_decode_strictmatters. A lenient decoder that skips invalid characters lets an attacker smuggle bytes past a filter that inspected the encoded form. Reject rather than repair.
Step 4: Mouse Encoding
Mouse encoding lives in terminal-input, but the modes live in terminal-core, and the encoder
reads them. This is the first place the two directions couple.
#![allow(unused)] fn main() { // crates/terminal-input/src/mouse.rs pub fn encode_mouse(ev: MouseEvent, modes: &TerminalModes) -> Option<Vec<u8>> { // Is this event even reportable under the current mode? let report = match ev.kind { MouseKind::Press | MouseKind::Release => modes.intersects(Mode::MOUSE_NORMAL | Mode::MOUSE_DRAG | Mode::MOUSE_ANY), MouseKind::Motion { any_button_held: true } => modes.intersects(Mode::MOUSE_DRAG | Mode::MOUSE_ANY), MouseKind::Motion { any_button_held: false } => modes.contains(Mode::MOUSE_ANY), MouseKind::Wheel { .. } => modes.intersects(Mode::MOUSE_NORMAL | Mode::MOUSE_DRAG | Mode::MOUSE_ANY), }; if !report { return None; } let mut button = base_button_code(&ev); if ev.mods.shift { button |= 4; } if ev.mods.alt { button |= 8; } if ev.mods.ctrl { button |= 16; } if matches!(ev.kind, MouseKind::Motion { .. }) { button |= 32; } if modes.contains(Mode::MOUSE_SGR) { // SGR encoding (?1006). Decimal, so no coordinate limit, and press and // release are distinguishable by the final byte. let final_byte = if matches!(ev.kind, MouseKind::Release) { 'm' } else { 'M' }; Some(format!("\x1b[<{};{};{}{}", button, ev.col + 1, ev.row + 1, final_byte).into_bytes()) } else { // Legacy X10. Each coordinate is ONE byte (value + 32), so columns and // rows above 223 CANNOT be represented. Clamp rather than emit garbage; // this limitation is exactly why ?1006 exists. if ev.col + 1 > 223 || ev.row + 1 > 223 { return None; } let release_code = if matches!(ev.kind, MouseKind::Release) { 3 } else { button }; Some(vec![0x1b, b'[', b'M', 32 + release_code as u8, 32 + (ev.col + 1) as u8, 32 + (ev.row + 1) as u8]) } } }
Step 5: Bracketed Paste, Safely
#![allow(unused)] fn main() { pub fn encode_paste(text: &str, modes: &TerminalModes) -> Vec<u8> { if !modes.contains(Mode::BRACKETED_PASTE) { // Not bracketed: the program cannot distinguish paste from typing. // At minimum, strip bytes that would immediately execute something. return text.bytes().filter(|&b| b != 0x00).collect(); } let mut out = Vec::with_capacity(text.len() + 12); out.extend_from_slice(b"\x1b[200~"); // CRITICAL: the payload must not be able to terminate its own envelope. // A clipboard containing "\x1b[201~rm -rf /\n" would otherwise end the // paste and have the remainder interpreted as typed input. This is a real, // exploited attack. let mut i = 0; let bytes = text.as_bytes(); while i < bytes.len() { if bytes[i..].starts_with(b"\x1b[201~") { i += 6; // drop the marker entirely continue; } // Also drop bare C0 controls except tab and newline, which are legitimate. if bytes[i] < 0x20 && bytes[i] != b'\t' && bytes[i] != b'\n' && bytes[i] != b'\r' { i += 1; continue; } out.push(bytes[i]); i += 1; } out.extend_from_slice(b"\x1b[201~"); out } }
Expected Output
$ mini-term run --rows 3 --cols 40 --format debug -- \
printf '\033]0;My Title\007hello\n'
title: "My Title"
--- screen ---
0 | hello
$ mini-term run --rows 3 --cols 40 --format debug -- \
printf '\033]8;;https://example.com\033\\link\033]8;;\033\\\n'
hyperlinks:
[1] id="" uri="https://example.com"
--- cells ---
(0,0)-(0,3) hyperlink=1
$ mini-term run --rows 3 --cols 40 --format debug -- \
printf '\033]8;;javascript:alert(1)\033\\bad\033]8;;\033\\\n'
hyperlinks: (none)
--- cells ---
(0,0)-(0,2) hyperlink=0 ← rejected scheme
Debugging Steps
The window title never updates
The GUI is not polling title_changed, or the OSC terminator is not being recognized (test both BEL
and ESC \).
Clicking does nothing in htop
Mouse modes not implemented, or the encoder is not consulting them. Record a session and grep for
?1000/?1002/?1006.
Mouse events arrive but at the wrong cell
Off-by-one: mouse coordinates are 1-based in the protocol and 0-based in your grid.
The shell fills with ^[[<0;10;5M after a program exits
Mouse reporting was left enabled. The program crashed without resetting. Your GUI needs a reset keybinding; the terminal itself cannot know.
Pasting into vim produces a staircase
Bracketed paste not implemented, or vim is not enabling it because your terminal did not advertise
it, or the mode is being reset by something.
An OSC never dispatches
Length cap hit, or the terminator is ESC \ and your StringEscape state is wrong.
Experiment
CLAIM. OSC 52 lets any process that can write to your terminal change your clipboard, and most terminals allow it by default.
METHOD.
# Create a file with an embedded clipboard write. This is harmless here, but
# the same bytes in a log file, a git commit message, or an SSH banner are not.
printf 'Normal looking text.\n\033]52;c;%s\033\\' "$(printf 'CLIPBOARD WAS REPLACED' | base64)" \
> /tmp/osc52-demo.txt
# Now cat it and check your clipboard.
cat /tmp/osc52-demo.txt
# ...paste somewhere.
# Then test the read direction (most terminals refuse):
printf '\033]52;c;?\033\\'
# If anything comes back on stdin, your terminal allows clipboard READS.
# Check with your Lab 1 inspector.
PREDICTION. Before running: does cat change your clipboard? Does the read return anything?
Which of the two would you consider a security bug, and would you ship either as a default?
RESULT. Record which terminals you tested and what each did. Then implement your policy accordingly.
Test
#![allow(unused)] fn main() { #[test] fn osc_title_accepts_both_terminators_and_strips_controls() { for input in [&b"\x1b]0;hi\x07"[..], &b"\x1b]0;hi\x1b\\"[..]] { let mut t = Terminal::new(3, 20); t.advance(input); assert_eq!(t.title(), "hi"); } let mut t = Terminal::new(3, 20); t.advance(b"\x1b]0;a\nb\x1b[31mc\x07"); assert_eq!(t.title(), "abc"); } #[test] fn unsafe_hyperlink_schemes_are_rejected() { for bad in ["javascript:alert(1)", "data:text/html,<script>", "vbscript:x"] { let mut t = Terminal::new(3, 40); t.advance(format!("\x1b]8;;{bad}\x1b\\X\x1b]8;;\x1b\\").as_bytes()); assert_eq!(t.screen().row(0).cell(0).style().hyperlink, 0, "{bad} must be rejected"); } } #[test] fn hyperlink_is_interned_and_style_stays_small() { let mut t = Terminal::new(3, 40); t.advance(b"\x1b]8;;https://example.com\x1b\\abcd\x1b]8;;\x1b\\"); let id = t.screen().row(0).cell(0).style().hyperlink; assert_ne!(id, 0); for i in 0..4 { assert_eq!(t.screen().row(0).cell(i).style().hyperlink, id); } assert!(std::mem::size_of::<Style>() <= 16); } #[test] fn clipboard_read_is_denied_by_default() { let mut t = Terminal::new(3, 20); t.advance(b"\x1b]52;c;?\x1b\\"); assert!(t.take_replies().is_empty()); assert!(t.take_clipboard_read_request().is_none()); } #[test] fn clipboard_write_rejects_invalid_base64() { let mut t = Terminal::new(3, 20); t.advance(b"\x1b]52;c;!!!not-base64!!!\x1b\\"); assert!(t.take_clipboard_write().is_none(), "strict decoding: reject rather than repair"); } #[test] fn bracketed_paste_payload_cannot_escape_its_envelope() { let mut modes = TerminalModes::default(); modes.insert(Mode::BRACKETED_PASTE); let out = encode_paste("safe\x1b[201~rm -rf /\n", &modes); let body = &out[6..out.len() - 6]; assert!(!contains(body, b"\x1b[201~")); } #[test] fn mouse_events_are_suppressed_when_the_mode_is_off() { let modes = TerminalModes::default(); assert!(encode_mouse(press_at(10, 5), &modes).is_none()); } #[test] fn sgr_mouse_encoding_has_no_coordinate_limit() { let mut modes = TerminalModes::default(); modes.insert(Mode::MOUSE_NORMAL | Mode::MOUSE_SGR); let out = encode_mouse(press_at(500, 300), &modes).unwrap(); assert_eq!(out, b"\x1b[<0;501;301M"); // The legacy encoding cannot represent this at all. let mut legacy = TerminalModes::default(); legacy.insert(Mode::MOUSE_NORMAL); assert!(encode_mouse(press_at(500, 300), &legacy).is_none()); } #[test] fn mouse_coordinates_are_one_based() { let mut modes = TerminalModes::default(); modes.insert(Mode::MOUSE_NORMAL | Mode::MOUSE_SGR); assert_eq!(encode_mouse(press_at(0, 0), &modes).unwrap(), b"\x1b[<0;1;1M"); } }
Challenge Extensions
- Implement OSC 133 and build prompt navigation on it: jump to the previous prompt, select the last command's output, mark failed commands.
- Implement OSC 4/10/11/12 with queries, and prove theming works end to end.
- Implement
?2026(synchronized output) with a 150 ms timeout, and measure tearing inneovimwith and without. - Add a clipboard
Askprompt to the GUI, and write the test that proves the default is write-only. - Implement
?1003and measure the event rate while sweeping the mouse across a full screen. Decide whether to coalesce motion events, and justify with the number. - Write a security test suite: a directory of files with embedded OSC 52 writes, OSC 52 reads, unsafe hyperlinks, oversized titles, unterminated strings, and title-report attempts. Assert your terminal does the safe thing for each. Keep it as a regression suite.
Deliverables
- OSC 0/1/2 (title), 4, 7, 8, 10–12, 52, 104, 133 dispatched; unknown OSC ignored safely.
- Titles sanitized and length-capped; no title reporting.
-
Hyperlinks interned with
id=grouping, URI length capped, schemes allow-listed. - A clipboard policy with reads denied by default, strict base64, and size caps.
-
Mouse modes
?1000/?1002/?1003/?1006with correct suppression and 1-based coordinates. - Bracketed paste with envelope-escape protection.
- All modes in the modes chapter stored, with correct defaults and DECRQM answers.
- The security test suite from challenge 6.
Validation / Self-check
- Name the three OSC terminators and which you must refuse.
- Why is a hyperlink URI interned rather than stored in the cell?
- Why are hyperlink schemes allow-listed rather than deny-listed?
- Why are OSC 52 reads denied by default? What is the exfiltration scenario?
- Why must base64 decoding be strict?
- What exactly must be stripped from a bracketed-paste payload, and why?
- Why does the legacy X10 mouse encoding fail above 223 columns, and what replaced it?
- Are mouse coordinates 0- or 1-based in the protocol? In your grid?
- A program crashes with
?1000and?1049set. What does the user see, and whose job is it to fix? - Why does
terminal-coreproduce a pending clipboard request rather than touching the clipboard itself?
Section 2 Complete
You have a terminal core that:
- Parses the VT grammar as a real state machine, fuzzed and bounded.
- Maintains a screen with correct wrapping, scrolling, regions, and an alternate buffer.
- Handles Unicode, including widths and combining marks, with enforced invariants.
- Implements the modes and string sequences real programs depend on.
- Runs headless, deterministically, in CI, with no display.
What you do not have: pixels. That is Section 3 — or, if you prefer multiplexers to fonts, skip straight to Section 4, which depends only on what you have now.