mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 00:21:46 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.?);
|
||||
|
||||
Reference in New Issue
Block a user