libghostty-vt: require opt-in for title reports (#13632)

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. Ghostty fixed
this a long time ago by making CSI 21 t an opt-in in the config. Do the
same but with our C/Zig API.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 09:32:45 -07:00
committed by GitHub
3 changed files with 111 additions and 1 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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;
},
@@ -2688,7 +2694,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);
@@ -2709,6 +2715,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.?);