diff --git a/rust/limux-ghostty-sys/src/lib.rs b/rust/limux-ghostty-sys/src/lib.rs index fe7755fe..33b6893a 100644 --- a/rust/limux-ghostty-sys/src/lib.rs +++ b/rust/limux-ghostty-sys/src/lib.rs @@ -77,6 +77,7 @@ pub const GHOSTTY_ACTION_DESKTOP_NOTIFICATION: c_int = 31; pub const GHOSTTY_ACTION_SET_TITLE: c_int = 32; pub const GHOSTTY_ACTION_PWD: c_int = 34; pub const GHOSTTY_ACTION_MOUSE_SHAPE: c_int = 35; +pub const GHOSTTY_ACTION_MOUSE_OVER_LINK: c_int = 37; pub const GHOSTTY_ACTION_COLOR_CHANGE: c_int = 45; pub const GHOSTTY_ACTION_RELOAD_CONFIG: c_int = 46; pub const GHOSTTY_ACTION_CONFIG_CHANGE: c_int = 47; @@ -326,6 +327,7 @@ pub union ghostty_action_u { pub set_title: ghostty_action_set_title_s, pub pwd: ghostty_action_pwd_s, pub open_url: ghostty_action_open_url_s, + pub mouse_over_link: ghostty_action_mouse_over_link_s, pub child_exited: ghostty_surface_message_childexited_s, _padding: [u8; 24], } @@ -365,6 +367,13 @@ pub struct ghostty_action_open_url_s { pub len: usize, } +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_mouse_over_link_s { + pub url: *const c_char, + pub len: usize, +} + #[repr(C)] #[derive(Clone, Copy)] pub struct ghostty_surface_message_childexited_s { diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 00f0753f..1ca4be0c 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -1182,11 +1182,77 @@ fn make_terminal_callbacks( } } +fn is_safe_browser_url(url: &str) -> bool { + // Minimal allow-list of URI schemes that may be handed to the system + // browser on Ctrl+click. The threat model is hostile terminal output: + // anything that ends up in a pane's scrollback can craft an OSC 8 + // hyperlink, and clicking it must not lead to code execution. + // + // Why only http/https/mailto? + // - `javascript:`, `vbscript:`, `data:` are classic XSS sinks (Gitea + // blocks these unconditionally — github.com/go-gitea/gitea#25960). + // - `file://` directly opens local files via the registered handler; + // a hostile `cat` of a crafted .desktop file would be RCE. + // - `ftp://`, `ftps://`, `smb://`, `nfs://`, `dav://`, `sftp://` all + // auto-mount via gvfs and can execute binaries on the mounted share + // (positive.security/blog/url-open-rce). + // - Custom schemes (`vscode://`, `slack://`, `obsidian://`, ...) have + // historically had RCE CVEs in their handlers; we don't second-guess + // that surface area here. + // + // RFC 3986 §3.1: scheme matching is case-insensitive. + let Some(colon) = url.find(':') else { + return false; + }; + let scheme = url[..colon].to_ascii_lowercase(); + let rest = &url[colon..]; + match scheme.as_str() { + "https" | "http" => rest.starts_with("://"), + "mailto" => rest.starts_with(':'), + _ => false, + } +} + fn open_url_in_external_browser(url: &str) { - if let Err(err) = - gtk::gio::AppInfo::launch_default_for_uri(url, None::<>k::gio::AppLaunchContext>) + if !is_safe_browser_url(url) { + eprintln!("limux: refusing to open URL with unrecognized scheme: {url}"); + return; + } + + // Use the GDK display's launch context so GIO emits an xdg-activation + // token. Without it, the target app (e.g. Firefox) receives the URL but + // Wayland refuses to let it raise its window — Konsole works because KIO + // wires the token in the same way. + if let Some(display) = gtk::gdk::Display::default() { + let context = display.app_launch_context(); + match gtk::gio::AppInfo::launch_default_for_uri(url, Some(&context)) { + Ok(_) => return, + Err(err) => { + eprintln!("limux: gio launch failed, falling back to xdg-open: {err}"); + } + } + } + + // Fallback: spawn xdg-open. Loses activation but at least delivers the URL + // in AppImage / sandboxed contexts where the bundled GIO can't dispatch. + // Reap the child in a detached thread — xdg-open exits as soon as it + // hands the URL off to the registered handler, and a dropped Child would + // otherwise linger as a entry until the host process exits. + match std::process::Command::new("xdg-open") + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() { - eprintln!("limux: failed to open URL in external browser: {err}"); + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } + Err(err) => { + eprintln!("limux: failed to spawn xdg-open for {url}: {err}"); + } } } @@ -3421,11 +3487,12 @@ fn create_browser_widget( mod tests { use super::{ classify_content_drop_zone, content_drop_preview_rect, effective_drop_target_dimensions, - is_localhost_input, next_active_after_tab_removal, normalize_browser_entry_input, - normalize_reorder_insert_index, pane_action_tooltip, surface_hint_matches, ContentDropZone, - TabDragPayload, BROWSER_SEARCH_ENTRY_CSS_CLASS, BROWSER_SEARCH_ENTRY_CSS_CLASSES, - BROWSER_URL_ENTRY_CSS_CLASS, BROWSER_URL_ENTRY_CSS_CLASSES, HOST_ENTRY_CSS_CLASS, PANE_CSS, - TAB_RENAME_ENTRY_CSS_CLASS, TAB_RENAME_ENTRY_CSS_CLASSES, + is_localhost_input, is_safe_browser_url, next_active_after_tab_removal, + normalize_browser_entry_input, normalize_reorder_insert_index, pane_action_tooltip, + surface_hint_matches, ContentDropZone, TabDragPayload, BROWSER_SEARCH_ENTRY_CSS_CLASS, + BROWSER_SEARCH_ENTRY_CSS_CLASSES, BROWSER_URL_ENTRY_CSS_CLASS, + BROWSER_URL_ENTRY_CSS_CLASSES, HOST_ENTRY_CSS_CLASS, PANE_CSS, TAB_RENAME_ENTRY_CSS_CLASS, + TAB_RENAME_ENTRY_CSS_CLASSES, }; #[cfg(feature = "webkit")] use super::{ @@ -3715,4 +3782,74 @@ mod tests { assert_eq!(normalize_browser_entry_input(input), expected, "{input}"); } } + + #[test] + fn is_safe_browser_url_accepts_navigable_schemes() { + // Web + email only — see the rationale on `is_safe_browser_url`. + for url in [ + "https://example.com", + "https://example.com/path?x=1&y=2", + "http://example.com", + "http://localhost:8080/foo", + "mailto:user@example.com", + "mailto:user@example.com?subject=hi", + ] { + assert!(is_safe_browser_url(url), "should accept {url}"); + } + } + + #[test] + fn is_safe_browser_url_is_scheme_case_insensitive() { + // RFC 3986 §3.1: scheme matching is case-insensitive. OSC 8 hyperlinks + // sometimes preserve the original case from upstream sources, so we + // must not reject syntactically valid uppercase/mixed-case schemes. + for url in [ + "HTTPS://example.com", + "Https://example.com", + "HTTP://example.com", + "MAILTO:user@example.com", + ] { + assert!(is_safe_browser_url(url), "should accept {url}"); + } + } + + #[test] + fn is_safe_browser_url_rejects_unsupported_or_dangerous_schemes() { + // Threat model: hostile terminal output can craft any OSC 8 hyperlink. + // The schemes below are either classic XSS sinks (`javascript:`, + // `data:`, `vbscript:`), gvfs auto-mount + exec vectors + // (`smb:`, `nfs:`, `dav:`, `davs:`, `sftp:`, `ftp:`, `ftps:`), local + // RCE via the file handler (`file:`), or app-specific URIs whose + // handlers have a history of RCE CVEs (`vscode:`, `slack:`, etc.). + // Leading whitespace and bare paths are also rejected as malformed. + for url in [ + "javascript:alert(1)", + "JavaScript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "File:///home/manu/notes.md", + "ftp://ftp.example.com/pub/file", + "ftps://ftp.example.com/pub/file", + "smb://server/share", + "nfs://server/export", + "dav://server/path", + "davs://server/path", + "sftp://user@host/path", + "ssh://user@host", + "magnet:?xt=urn:btih:abc", + "chrome://settings", + "about:blank", + "vscode://file/path", + "slack://open?team=T", + " https://example.com", + "/etc/passwd", + "example.com", + "", + "https:", + "http:/example.com", + ] { + assert!(!is_safe_browser_url(url), "should reject {url:?}"); + } + } } diff --git a/rust/limux-host-linux/src/terminal.rs b/rust/limux-host-linux/src/terminal.rs index 4bd00101..f44d29f6 100644 --- a/rust/limux-host-linux/src/terminal.rs +++ b/rust/limux-host-linux/src/terminal.rs @@ -52,6 +52,12 @@ pub struct TerminalIdentity { pub surface_id: String, } +/// Vertical pixel gap between the cursor and the bottom edge of the hover +/// URL preview popover. Roughly the height of a large accessibility cursor +/// so the popover stays visible even when the user has a >32 px pointer +/// skin enabled. +const LINK_PREVIEW_CURSOR_Y_GAP: i32 = 14; + /// Per-surface state, stored in a global registry keyed by surface pointer. struct SurfaceEntry { gl_area: gtk::GLArea, @@ -67,6 +73,12 @@ struct SurfaceEntry { on_close: Option>, open_url_external: Rc>, clipboard_context: *mut ClipboardContext, + // Hover URL preview for OSC 8 hyperlinks. The popover is a child of + // `gl_area` so it inherits libadwaita's popover styling — matching the + // right-click context menu by construction. + link_popover: gtk::Popover, + link_label: gtk::Label, + cursor_pos: Rc>, } struct ClipboardContext { @@ -767,6 +779,51 @@ unsafe extern "C" fn ghostty_action_cb( } true } + GHOSTTY_ACTION_MOUSE_OVER_LINK => { + // Ghostty emits this when the cursor enters / leaves a hyperlink + // region (only while the link is in its "clickable" state — i.e. + // Ctrl held). Show the target URL in a libadwaita popover so the + // user can see where an OSC 8 labelled link actually points + // before they click — defence against deceptive link-masking. + if target.tag == GHOSTTY_TARGET_SURFACE { + let surface_key = unsafe { target.target.surface } as usize; + let payload = unsafe { action.action.mouse_over_link }; + let url = if payload.url.is_null() || payload.len == 0 { + None + } else { + let bytes = unsafe { + std::slice::from_raw_parts(payload.url.cast::(), payload.len) + }; + Some(String::from_utf8_lossy(bytes).to_string()) + }; + SURFACE_MAP.with(|map| { + if let Some(entry) = map.borrow().get(&surface_key) { + match url { + Some(url) => { + entry.link_label.set_text(&url); + let (x, y) = entry.cursor_pos.get(); + // GTK default: PositionType::Top centers the + // popover horizontally on the rectangle and + // anchors its bottom edge to the rectangle's + // top edge. The Y-gap keeps the popover clear + // of large accessibility cursor skins. + entry.link_popover.set_pointing_to(Some( + >k::gdk::Rectangle::new( + x as i32, + (y as i32).saturating_sub(LINK_PREVIEW_CURSOR_Y_GAP), + 1, + 1, + ), + )); + entry.link_popover.popup(); + } + None => entry.link_popover.popdown(), + } + } + }); + } + true + } GHOSTTY_ACTION_OPEN_URL => { if target.tag == GHOSTTY_TARGET_SURFACE { let surface_key = unsafe { target.target.surface } as usize; @@ -1146,6 +1203,20 @@ pub fn create_terminal( let open_url_external = Rc::new(Cell::new(false)); let clipboard_context_cell: Rc> = Rc::new(Cell::new(ptr::null_mut())); + let cursor_pos: Rc> = Rc::new(Cell::new((0.0, 0.0))); + + // Popover used by the OSC 8 hover preview. Built via the same helpers + // as the right-click context menu so the look matches by construction. + let link_label = gtk::Label::new(None); + link_label.set_selectable(false); + let link_inner = build_popover_inner_box(); + link_inner.append(&link_label); + let link_popover = build_floating_popover(&gl_area, &link_inner); + link_popover.set_autohide(false); + // Above the cursor by default, web-browser style. GTK auto-flips to + // Bottom if there isn't enough room above the pointing rectangle. + link_popover.set_position(gtk::PositionType::Top); + link_popover.set_can_focus(false); // Create overlay early so closures can capture it for toast notifications let overlay = gtk::Overlay::new(); @@ -1272,6 +1343,9 @@ pub fn create_terminal( let overlay_for_map = overlay.clone(); let scrollbar_for_map = scrollbar.clone(); let scrollbar_adjustment_for_map = scrollbar_adjustment.clone(); + let link_popover_for_map = link_popover.clone(); + let link_label_for_map = link_label.clone(); + let cursor_pos_for_map = cursor_pos.clone(); let surface_cell = surface_cell.clone(); let callbacks = callbacks.clone(); let had_focus = had_focus.clone(); @@ -1443,6 +1517,9 @@ pub fn create_terminal( })), open_url_external: open_url_external_for_map.clone(), clipboard_context, + link_popover: link_popover_for_map.clone(), + link_label: link_label_for_map.clone(), + cursor_pos: cursor_pos_for_map.clone(), }, ); }); @@ -1682,6 +1759,9 @@ pub fn create_terminal( let surface_cell_for_enter = surface_cell.clone(); let gl_for_focus = gl_area.clone(); let had_focus = had_focus.clone(); + let cursor_pos_enter = cursor_pos.clone(); + let cursor_pos_motion = cursor_pos.clone(); + let link_popover_motion = link_popover.clone(); let motion = gtk::EventControllerMotion::new(); motion.connect_enter(move |ctrl, x, y| { if (hover_focus)() { @@ -1691,6 +1771,7 @@ pub fn create_terminal( request_terminal_focus(&gl_for_focus, &had_focus); } + cursor_pos_enter.set((x, y)); if let Some(surface) = *surface_cell_for_enter.borrow() { let mods = translate_mouse_mods(ctrl.current_event_state()); unsafe { ghostty_surface_mouse_pos(surface, x, y, mods) }; @@ -1698,6 +1779,18 @@ pub fn create_terminal( }); let surface_cell = surface_cell.clone(); motion.connect_motion(move |ctrl, x, y| { + cursor_pos_motion.set((x, y)); + // Keep the URL preview tracking the cursor while it stays inside + // a clickable link region (Ghostty only emits MOUSE_OVER_LINK + // on enter/leave, not on every motion). + if link_popover_motion.is_visible() { + link_popover_motion.set_pointing_to(Some(>k::gdk::Rectangle::new( + x as i32, + (y as i32).saturating_sub(LINK_PREVIEW_CURSOR_Y_GAP), + 1, + 1, + ))); + } if let Some(surface) = *surface_cell.borrow() { let mods = translate_mouse_mods(ctrl.current_event_state()); unsafe { ghostty_surface_mouse_pos(surface, x, y, mods) }; @@ -1859,6 +1952,33 @@ fn copy_text_to_clipboards(text: &str) { } } +/// Build a libadwaita-styled floating popover (no arrow), parented to +/// `parent`. Shared by the right-click context menu and the OSC 8 hover +/// URL preview so they look identical by construction. The caller is +/// responsible for `set_pointing_to` and `popup`/`popdown`. +fn build_floating_popover( + parent: &impl IsA, + child: &impl IsA, +) -> gtk::Popover { + let popover = gtk::Popover::new(); + popover.set_child(Some(child)); + popover.set_has_arrow(false); + popover.set_parent(parent); + popover +} + +/// 4 px box wrapper that matches the inner margin used by the right-click +/// context menu items. Reused for the hover preview so both popovers have +/// the same visual breathing room around their content. +fn build_popover_inner_box() -> gtk::Box { + let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + menu_box.set_margin_top(4); + menu_box.set_margin_bottom(4); + menu_box.set_margin_start(4); + menu_box.set_margin_end(4); + menu_box +} + fn show_terminal_context_menu( gl_area: >k::GLArea, overlay: >k::Overlay, @@ -1867,11 +1987,7 @@ fn show_terminal_context_menu( x: f64, y: f64, ) { - let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0); - menu_box.set_margin_top(4); - menu_box.set_margin_bottom(4); - menu_box.set_margin_start(4); - menu_box.set_margin_end(4); + let menu_box = build_popover_inner_box(); let has_selection = surface .map(|s| unsafe { ghostty_surface_has_selection(s) }) @@ -1946,10 +2062,7 @@ fn show_terminal_context_menu( menu_box.append(&btn); } - let popover = gtk::Popover::new(); - popover.set_child(Some(&menu_box)); - popover.set_parent(gl_area); - popover.set_has_arrow(false); + let popover = build_floating_popover(gl_area, &menu_box); popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); // Wire up each button