diff --git a/src/Surface.zig b/src/Surface.zig index 476359dbe..66f5dee70 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -142,6 +142,7 @@ const DerivedConfig = struct { clipboard_read: bool, clipboard_write: bool, clipboard_trim_trailing_spaces: bool, + clipboard_paste_protection: bool, copy_on_select: configpkg.CopyOnSelect, confirm_close_surface: bool, mouse_interval: u64, @@ -165,6 +166,7 @@ const DerivedConfig = struct { .clipboard_read = config.@"clipboard-read", .clipboard_write = config.@"clipboard-write", .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", + .clipboard_paste_protection = config.@"clipboard-paste-protection", .copy_on_select = config.@"copy-on-select", .confirm_close_surface = config.@"confirm-close-surface", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms @@ -1163,7 +1165,7 @@ pub fn keyCallback( /// if bracketed mode is on this will do a bracketed paste. Otherwise, /// this will filter newlines to '\r'. pub fn textCallback(self: *Surface, text: []const u8) !void { - try self.completeClipboardPaste(text); + try self.completeClipboardPaste(text, true); } pub fn focusCallback(self: *Surface, focused: bool) !void { @@ -2177,7 +2179,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // If you split it across two then the shell can interpret it // as two literals. var buf: [128]u8 = undefined; - const full_data = try std.fmt.bufPrint(&buf, "\x1b{s}{s}", .{if(action==.csi)"["else"", data}); + const full_data = try std.fmt.bufPrint(&buf, "\x1b{s}{s}", .{ if (action == .csi) "[" else "", data }); _ = self.io_thread.mailbox.push(try termio.Message.writeReq( self.alloc, full_data, @@ -2453,13 +2455,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool /// Call this to complete a clipboard request sent to apprt. This should /// only be called once for each request. The data is immediately copied so /// it is safe to free the data after this call. +/// +/// If "allow_unsafe" is false, then the data is checked for "safety" prior. +/// If unsafe data is detected, this will return error.UnsafePaste. Unsafe +/// data is defined as data that contains newlines, though this definition +/// may change later to detect other scenarios. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, data: []const u8, + allow_unsafe: bool, ) !void { switch (req) { - .paste => try self.completeClipboardPaste(data), + .paste => try self.completeClipboardPaste(data, allow_unsafe), .osc_52 => |kind| try self.completeClipboardReadOSC52(data, kind), } } @@ -2485,9 +2493,24 @@ fn startClipboardRequest( try self.rt_surface.clipboardRequest(loc, req); } -fn completeClipboardPaste(self: *Surface, data: []const u8) !void { +fn completeClipboardPaste( + self: *Surface, + data: []const u8, + allow_unsafe: bool, +) !void { if (data.len == 0) return; + // If we have paste protection enabled, we detect unsafe pastes and return + // an error. The error approach allows apprt to attempt to complete the paste + // before falling back to requesting confirmation. + if (self.config.clipboard_paste_protection and + !allow_unsafe and + !terminal.isSafePaste(data)) + { + log.info("potentially unsafe paste detected, rejecting until confirmation", .{}); + return error.UnsafePaste; + } + const bracketed = bracketed: { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c4ae49e88..b7ae1c6ee 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1360,7 +1360,8 @@ pub const CAPI = struct { if (str_len == 0) return; const str = str_ptr[0..str_len]; - ptr.core_surface.completeClipboardRequest(state.*, str) catch |err| { + // TODO: Support sanaization for MacOS (force: false) + ptr.core_surface.completeClipboardRequest(state.*, str, true) catch |err| { log.err("error completing clipboard request err={}", .{err}); return; }; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index be4103f77..41bfae892 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -588,8 +588,9 @@ pub const Surface = struct { }, }; - // Complete our request - try self.core_surface.completeClipboardRequest(state, str); + // Complete our request. We always allow unsafe because we don't + // want to deal with user confirmation in this runtime. + try self.core_surface.completeClipboardRequest(state, str, true); } /// Set the clipboard. diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 5e5c143b4..68a2d1d0d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -24,6 +24,7 @@ const build_options = @import("build_options"); const Surface = @import("Surface.zig"); const Window = @import("Window.zig"); const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig"); +const UnsafePasteWindow = @import("UnsafePasteWindow.zig"); const c = @import("c.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); @@ -47,6 +48,9 @@ menu: ?*c.GMenu = null, /// The configuration errors window, if it is currently open. config_errors_window: ?*ConfigErrorsWindow = null, +/// The unsafe paste window, if it is currently open. +unsafe_paste_window: ?*UnsafePasteWindow = null, + /// This is set to false when the main loop should exit. running: bool = true, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 1ec79184c..99acf2f66 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -13,6 +13,7 @@ const CoreSurface = @import("../../Surface.zig"); const App = @import("App.zig"); const Window = @import("Window.zig"); +const UnsafePasteWindow = @import("UnsafePasteWindow.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig"); @@ -546,10 +547,27 @@ fn gtkClipboardRead( return; } defer c.g_free(cstr); - const str = std.mem.sliceTo(cstr, 0); - self.core_surface.completeClipboardRequest(req.state, str) catch |err| { - log.err("failed to complete clipboard request err={}", .{err}); + + self.core_surface.completeClipboardRequest( + req.state, + str, + false, + ) catch |err| switch (err) { + error.UnsafePaste => { + // Create a dialog and ask the user if they want to paste anyway. + UnsafePasteWindow.create( + self.app, + str, + self.core_surface, + req.state, + ) catch |window_err| { + log.err("failed to create unsafe paste window err={}", .{window_err}); + }; + return; + }, + + else => log.err("failed to complete clipboard request err={}", .{err}), }; } @@ -559,7 +577,6 @@ fn getClipboard(widget: *c.GtkWidget, clipboard: apprt.Clipboard) ?*c.GdkClipboa .selection => c.gtk_widget_get_primary_clipboard(widget), }; } - pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { return self.cursor_pos; } diff --git a/src/apprt/gtk/UnsafePasteWindow.zig b/src/apprt/gtk/UnsafePasteWindow.zig new file mode 100644 index 000000000..e3582e892 --- /dev/null +++ b/src/apprt/gtk/UnsafePasteWindow.zig @@ -0,0 +1,219 @@ +/// Unsafe Paste Window +const UnsafePaste = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const CoreSurface = @import("../../Surface.zig"); +const ClipboardRequest = @import("../structs.zig").ClipboardRequest; +const App = @import("App.zig"); +const View = @import("View.zig"); +const c = @import("c.zig"); + +const log = std.log.scoped(.gtk); + +app: *App, +window: *c.GtkWindow, +view: PrimaryView, + +data: []u8, +core_surface: CoreSurface, +pending_req: ClipboardRequest, + +pub fn create( + app: *App, + data: []const u8, + core_surface: CoreSurface, + request: ClipboardRequest, +) !void { + if (app.unsafe_paste_window != null) return error.WindowAlreadyExists; + + const alloc = app.core_app.alloc; + const self = try alloc.create(UnsafePaste); + errdefer alloc.destroy(self); + try self.init( + app, + data, + core_surface, + request, + ); + + app.unsafe_paste_window = self; +} + +/// Not public because this should be called by the GTK lifecycle. +fn destroy(self: *UnsafePaste) void { + const alloc = self.app.core_app.alloc; + self.app.unsafe_paste_window = null; + alloc.destroy(self); +} + +fn init( + self: *UnsafePaste, + app: *App, + data: []const u8, + core_surface: CoreSurface, + request: ClipboardRequest, +) !void { + // Create the window + const window = c.gtk_window_new(); + const gtk_window: *c.GtkWindow = @ptrCast(window); + errdefer c.gtk_window_destroy(gtk_window); + c.gtk_window_set_title(gtk_window, "Warning: Potentially Unsafe Paste"); + c.gtk_window_set_default_size(gtk_window, 550, 275); + c.gtk_window_set_resizable(gtk_window, 0); + _ = c.g_signal_connect_data( + window, + "destroy", + c.G_CALLBACK(>kDestroy), + self, + null, + c.G_CONNECT_DEFAULT, + ); + + // Set some state + self.* = .{ + .app = app, + .window = gtk_window, + .view = undefined, + .data = try app.core_app.alloc.dupe(u8, data), + .core_surface = core_surface, + .pending_req = request, + }; + + // Show the window + const view = try PrimaryView.init(self, data); + self.view = view; + c.gtk_window_set_child(@ptrCast(window), view.root); + c.gtk_widget_show(window); + + // Block the main window from input. + // This will auto-revert when the window is closed. + c.gtk_window_set_modal(gtk_window, 1); +} + +fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { + const self: *UnsafePaste = @ptrCast(@alignCast(ud orelse return)); + self.destroy(); +} + +const PrimaryView = struct { + root: *c.GtkWidget, + text: *c.GtkTextView, + + pub fn init(root: *UnsafePaste, data: []const u8) !PrimaryView { + // All our widgets + const label = c.gtk_label_new( + "Pasting this text into the terminal may be dangerous as " ++ + "it looks like some commands may be executed.", + ); + const buf = unsafeBuffer(data); + defer c.g_object_unref(buf); + const buttons = try ButtonsView.init(root); + const text_scroll = c.gtk_scrolled_window_new(); + errdefer c.g_object_unref(text_scroll); + const text = c.gtk_text_view_new_with_buffer(buf); + errdefer c.g_object_unref(text); + c.gtk_scrolled_window_set_child(@ptrCast(text_scroll), text); + + // Create our view + const view = try View.init(&.{ + .{ .name = "label", .widget = label }, + .{ .name = "text", .widget = text_scroll }, + .{ .name = "buttons", .widget = buttons.root }, + }, &vfl); + errdefer view.deinit(); + + // We can do additional settings once the layout is setup + c.gtk_label_set_wrap(@ptrCast(label), 1); + c.gtk_text_view_set_editable(@ptrCast(text), 0); + c.gtk_text_view_set_cursor_visible(@ptrCast(text), 0); + c.gtk_text_view_set_top_margin(@ptrCast(text), 8); + c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8); + c.gtk_text_view_set_left_margin(@ptrCast(text), 8); + c.gtk_text_view_set_right_margin(@ptrCast(text), 8); + + return .{ .root = view.root, .text = @ptrCast(text) }; + } + + /// Returns the GtkTextBuffer for the data that was unsafe. + fn unsafeBuffer(data: []const u8) *c.GtkTextBuffer { + const buf = c.gtk_text_buffer_new(null); + errdefer c.g_object_unref(buf); + + c.gtk_text_buffer_insert_at_cursor(buf, data.ptr, @intCast(data.len)); + + return buf; + } + + const vfl = [_][*:0]const u8{ + "H:|-8-[label]-8-|", + "H:|[text]|", + "H:|[buttons]|", + "V:|[label(<=80)][text(>=100)]-[buttons]-|", + }; +}; + +const ButtonsView = struct { + root: *c.GtkWidget, + + pub fn init(root: *UnsafePaste) !ButtonsView { + const cancel_button = c.gtk_button_new_with_label("Cancel"); + errdefer c.g_object_unref(cancel_button); + + const paste_button = c.gtk_button_new_with_label("Paste"); + errdefer c.g_object_unref(paste_button); + + // TODO: Focus on the paste button + // c.gtk_widget_grab_focus(paste_button); + + // Create our view + const view = try View.init(&.{ + .{ .name = "cancel", .widget = cancel_button }, + .{ .name = "paste", .widget = paste_button }, + }, &vfl); + + // Signals + _ = c.g_signal_connect_data( + cancel_button, + "clicked", + c.G_CALLBACK(>kCancelClick), + root, + null, + c.G_CONNECT_DEFAULT, + ); + _ = c.g_signal_connect_data( + paste_button, + "clicked", + c.G_CALLBACK(>kPasteClick), + root, + null, + c.G_CONNECT_DEFAULT, + ); + + return .{ .root = view.root }; + } + + fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { + const self: *UnsafePaste = @ptrCast(@alignCast(ud)); + c.gtk_window_destroy(@ptrCast(self.window)); + } + + fn gtkPasteClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { + // Requeue the paste with force. + const self: *UnsafePaste = @ptrCast(@alignCast(ud)); + self.core_surface.completeClipboardRequest( + self.pending_req, + self.data, + true, + ) catch |err| { + std.log.err("Failed to requeue clipboard request: {}", .{err}); + }; + + c.gtk_window_destroy(@ptrCast(self.window)); + } + + const vfl = [_][*:0]const u8{ + "H:[cancel]-8-[paste]-8-|", + }; +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index ba29ed6f2..fbb92e5ba 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -423,6 +423,13 @@ keybind: Keybinds = .{}, /// This does not affect data sent to the clipboard via "clipboard-write". @"clipboard-trim-trailing-spaces": bool = true, +/// Require confirmation before pasting text that appears unsafe. This helps +/// prevent a "copy/paste attack" where a user may accidentally execute unsafe +/// commands by pasting text with newlines. +/// +/// This currently only works on Linux (GTK). +@"clipboard-paste-protection": bool = true, + /// The total amount of bytes that can be used for image data (i.e. /// the Kitty image protocol) per terminal scren. The maximum value /// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 8c0a7fc74..a752d64eb 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -1,5 +1,7 @@ const builtin = @import("builtin"); +pub usingnamespace @import("sanitize.zig"); + const charsets = @import("charsets.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); diff --git a/src/terminal/sanitize.zig b/src/terminal/sanitize.zig new file mode 100644 index 000000000..f492291aa --- /dev/null +++ b/src/terminal/sanitize.zig @@ -0,0 +1,13 @@ +const std = @import("std"); + +/// Returns true if the data looks safe to paste. +pub fn isSafePaste(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\n") == null; +} + +test isSafePaste { + const testing = std.testing; + try testing.expect(isSafePaste("hello")); + try testing.expect(!isSafePaste("hello\n")); + try testing.expect(!isSafePaste("hello\nworld")); +}