From 38e891e6c0bbaa50661b529424fc9fdd866ae252 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 5 Aug 2026 09:17:03 -0700 Subject: [PATCH] terminal: require opt-in for title reports Add an explicit libghostty-vt title-report option and keep CSI 21 t disabled unless an embedder enables it. Previously, registering the general PTY write callback also caused the terminal to echo attacker-controlled window titles. This exposed embedders to command injection after user interaction. Gate the response in the shared terminal stream, append the C API option without renumbering existing values, and cover the default, opt-in, and reset behavior in Zig and C API tests. --- include/ghostty/vt/terminal.h | 12 ++++++ src/terminal/c/terminal.zig | 65 ++++++++++++++++++++++++++++++++ src/terminal/stream_terminal.zig | 35 ++++++++++++++++- 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 7013029e4..7e88373dc 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1048,6 +1048,18 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES = 31, + + /** + * Enable window title reports in response to CSI 21 t. + * + * This is disabled by default because a running program can set a title and + * query it back into the pty input stream, potentially injecting commands + * that execute after user interaction. Passing NULL or a pointer to false + * disables title reporting. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_TITLE_REPORT = 32, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 7ca8045d2..1a1bce0bd 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -889,6 +889,7 @@ pub const Option = enum(c_int) { desktop_notification = 29, progress_report = 30, continuation_max_bytes = 31, + title_report = 32, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -913,6 +914,7 @@ pub const Option = enum(c_int) { .kitty_image_medium_file, .kitty_image_medium_shared_mem, .glyph_protocol, + .title_report, => ?*const bool, .kitty_image_medium_temp_file => ?*const lib.String, .apc_max_bytes, @@ -970,6 +972,10 @@ fn setTyped( .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, .clipboard_write => wrapper.effects.clipboard_write = value, + .title_report => wrapper.stream.handler.title_report = if (value) |ptr| + ptr.* + else + false, .title => { const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setTitle(str) catch return .out_of_memory; @@ -4343,6 +4349,65 @@ test "get title set via vt_write" { try testing.expectEqualStrings("VT Title", title.ptr[0..title.len]); } +test "title report requires explicit opt in" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |data| testing.allocator.free(data); + last_data = null; + } + + fn writePty( + _: Terminal, + _: ?*anyopaque, + ptr: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) void { + if (last_data) |data| testing.allocator.free(data); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + S.last_data = null; + defer S.deinit(); + + try testing.expectEqual( + Result.success, + set(t, .write_pty, @ptrCast(&S.writePty)), + ); + + const set_title = "\x1B]2;echo vulnerable\x1B\\"; + const query_title = "\x1B[21t"; + vt_write(t, set_title, set_title.len); + + // WRITE_PTY alone must not enable the security-sensitive response. + vt_write(t, query_title, query_title.len); + try testing.expect(S.last_data == null); + + const enabled = true; + try testing.expectEqual(Result.success, set(t, .title_report, &enabled)); + vt_write(t, query_title, query_title.len); + try testing.expectEqualStrings( + "\x1b]lecho vulnerable\x1b\\", + S.last_data.?, + ); + + // NULL restores the secure default. + S.deinit(); + try testing.expectEqual(Result.success, set(t, .title_report, null)); + vt_write(t, query_title, query_title.len); + try testing.expect(S.last_data == null); +} + test "resize updates pixel dimensions" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 72daffb15..e9d27fc35 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -56,6 +56,11 @@ pub const Handler = struct { /// effects. effects: Effects = .readonly, + /// Whether CSI 21 t may report the terminal title. This is disabled by + /// default because reporting an attacker-controlled title to the pty can + /// inject text into the input stream of the foreground process. + title_report: bool = false, + /// The APC command handler maintains the APC state. APC is like /// CSI or OSC, but it is a private escape sequence that is used /// to send commands to the terminal emulator. This is used by @@ -565,6 +570,7 @@ pub const Handler = struct { // Build the response. switch (style) { .csi_21_t => { + if (!self.title_report) return; const title = self.terminal.getTitle() orelse ""; aw.writer.print("\x1b]l{s}\x1b\\", .{title}) catch return; }, @@ -2684,7 +2690,7 @@ test "size report no effect callback" { try testing.expect(S.written == null); } -test "size report csi_21_t title" { +test "size report csi_21_t title disabled by default" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -2705,6 +2711,33 @@ test "size report csi_21_t title" { // Set a title first s.nextSlice("\x1b]2;My Title\x1b\\"); + // CSI 21 t - report title (no size effect needed) + s.nextSlice("\x1b[21t"); + try testing.expect(S.written == null); +} + +test "size report csi_21_t title enabled" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.title_report = true; + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Set a title first + s.nextSlice("\x1b]2;My Title\x1b\\"); + // CSI 21 t - report title (no size effect needed) s.nextSlice("\x1b[21t"); defer testing.allocator.free(S.written.?);