fix(ui): hover-panic on non-ASCII links, paste and clipboard spoofing, image budget, links

- is_web_url sliced the string at byte 7/8, panicking (and killing every
  session) when a remote host printed an OSC 8 or plain link with a
  multi-byte character at that offset and the user hovered it. It now
  compares bytes.
- Paste confirmation also triggers on control characters other than tab
  (readline's C-o runs a line with no newline), and the preview shows
  them escaped.
- Remote clipboard toasts escape control characters and state the line
  count, so 'ls\ncurl … | sh' no longer reads as a harmless 'ls'.
- Inline images: dimensions are read from the header first and anything
  over the 32 Mpx per-terminal budget is refused before decoding.
- Toasts: repeats extend the visible toast and at most 8 are kept, so
  trigger or OSC 52 floods can't stall frames.
- The URL regex is compiled once.
- Enable eframe's 'links' feature: without it every 'open in browser'
  (Cmd/Ctrl+click, GitHub repo links, the update link) was a silent
  no-op. Adds webbrowser 1.2.4 to the lockfile.
This commit is contained in:
Andy Dixon
2026-10-13 06:32:37 +01:00
parent 00f110f39b
commit d6f8f3306b
7 changed files with 119 additions and 15 deletions
+17
View File
@@ -1800,6 +1800,7 @@ dependencies = [
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
"winit",
]
@@ -7074,6 +7075,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webbrowser"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
+1 -1
View File
@@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
nexus-core = { path = "../core" }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence", "links"] }
egui = "0.36"
parking_lot = "0.12"
zeroize = "1"
+20 -2
View File
@@ -791,10 +791,9 @@ impl App {
/// A remote host (OSC 52 or VNC) wants to set the clipboard: allowed per preference, and always
/// announced so a silent swap can't set up a paste into another shell.
pub fn remote_clipboard(&mut self, text: &str) {
let first: String = text.lines().next().unwrap_or("").chars().take(60).collect();
if self.s.prefs.allow_remote_clipboard {
self.s.ctx.copy_text(text.into());
self.s.info(format!("Remote host set the clipboard: {first}"));
self.s.info(format!("Remote host set the clipboard: {}", clipboard_summary(text)));
} else {
self.s.info("Blocked a remote clipboard write (Settings → General to allow)");
}
@@ -1077,3 +1076,22 @@ impl eframe::App for App {
let _ = ToastKind::Info;
}
}
/// How a remote clipboard write is announced: control characters made visible and the line
/// count stated, so `ls\ncurl … | sh` can't pass for a harmless `ls`.
fn clipboard_summary(text: &str) -> String {
let lines = text.lines().count();
let shown: String = text.chars().take(60).flat_map(|c| if c.is_control() { c.escape_debug().collect::<Vec<_>>() } else { vec![c] }).collect();
let more = if text.chars().count() > 60 { "…" } else { "" };
if lines > 1 { format!("{shown}{more} ({lines} lines)") } else { format!("{shown}{more}") }
}
#[cfg(test)]
mod clipboard_tests {
#[test]
fn remote_clipboard_toast_shows_hidden_lines() {
assert_eq!(super::clipboard_summary("ls"), "ls");
assert_eq!(super::clipboard_summary("ls\ncurl x|sh\n"), "ls\\ncurl x|sh\\n (2 lines)");
assert!(super::clipboard_summary("a\x0fb").contains("\\u{f}"));
}
}
+7 -1
View File
@@ -931,7 +931,13 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) {
}
Dialog::ConfirmPaste { session, text } => {
let lines = text.lines().count();
let preview: String = text.lines().take(12).collect::<Vec<_>>().join("\n");
// Control characters other than newline/tab act on their own; show them.
let preview: String = text
.lines()
.take(12)
.map(|l| l.chars().flat_map(|c| if c.is_control() && c != '\t' { c.escape_debug().collect::<Vec<_>>() } else { vec![c] }).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
modal(ctx, "confirm-paste", 480.0, |ui| {
ui.label(RichText::new("Paste multiple lines?").font(theme::semibold(15.0)));
widgets::hint(ui, &format!("{lines} line(s) will run as commands as soon as they are pasted:"));
+13 -2
View File
@@ -45,6 +45,8 @@ pub enum SettingsTab {
About,
}
const MAX_TOASTS: usize = 8;
pub struct Toast {
pub text: String,
pub kind: ToastKind,
@@ -129,8 +131,17 @@ impl Shared {
}
fn push(&mut self, text: impl Into<String>, kind: ToastKind) {
let secs = if kind == ToastKind::Error { 6.0 } else { 4.0 };
self.toasts.push(Toast { text: text.into(), kind, until: self.now + secs });
let (text, secs) = (text.into(), if kind == ToastKind::Error { 6.0 } else { 4.0 });
// Remote output can raise toasts (triggers, clipboard writes) at line rate: repeats only
// extend the visible one, and at most a handful are kept so a flood can't stall frames.
if let Some(t) = self.toasts.iter_mut().find(|t| t.text == text && t.kind == kind) {
t.until = self.now + secs;
} else {
self.toasts.push(Toast { text, kind, until: self.now + secs });
if self.toasts.len() > MAX_TOASTS {
self.toasts.remove(0);
}
}
self.ctx.request_repaint();
}
+42 -6
View File
@@ -280,11 +280,18 @@ impl TermView {
return;
}
let decoded = (|| {
let mut r = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;
let reader = || image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok();
// Read the header first: refuse anything larger than the per-terminal image budget
// before decoding, since the decoder's own limits don't bound every internal buffer.
let (w, h) = reader()?.into_dimensions().ok()?;
if w as usize * h as usize > MAX_IMAGE_PIXELS {
return None;
}
let mut r = reader()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(256 << 20);
limits.max_alloc = Some((MAX_IMAGE_PIXELS * 4) as u64);
r.limits(limits);
Some(r.decode().ok()?.to_rgba8())
})();
@@ -707,9 +714,11 @@ impl TermView {
}
/// Pastes `text`, or hands it back in `out.confirm_paste` when `confirm_paste` is set and the
/// newlines would run each line immediately (no bracketed paste).
/// paste could act by itself without bracketed paste: newlines run lines, and other control
/// characters do too (readline's C-o runs the line, C-d exits, ESC drives key macros).
pub fn paste(&mut self, text: &str, out: &mut TermResponse) {
if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && text.contains(['\n', '\r']) {
let acts = text.chars().any(|c| c.is_control() && c != '\t');
if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && acts {
out.confirm_paste = Some(text.into());
return;
}
@@ -871,8 +880,8 @@ impl TermView {
return Some(link);
}
let (text, cols) = self.line_text(point.line);
let re = regex::Regex::new(URL_RE).ok()?;
re.find_iter(&text).find_map(|m| {
static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| regex::Regex::new(URL_RE).expect("URL_RE is valid"));
RE.find_iter(&text).find_map(|m| {
let start = text[..m.start()].chars().count();
let end = start + m.as_str().chars().count() - 1;
(cols.get(start)? <= &point.column.0 && cols.get(end)? >= &point.column.0).then(|| m.as_str().to_string())
@@ -1226,12 +1235,39 @@ mod tests {
let mut out = TermResponse::default();
t.paste("one line", &mut out);
assert_eq!(out.input, b"one line");
// Control characters that act without a newline need the prompt too (C-o runs the line).
let mut out = TermResponse::default();
t.paste("curl x|sh\x0f", &mut out);
assert!(out.input.is_empty() && out.confirm_paste.is_some());
let mut out = TermResponse::default();
t.paste("a\tb", &mut out);
assert_eq!(out.input, b"a\tb", "a tab is fine");
t.feed(b"\x1b[?2004h");
let mut out = TermResponse::default();
t.paste("a\nb", &mut out);
assert!(out.confirm_paste.is_none() && out.input.starts_with(b"\x1b[200~"), "bracketed paste needs no prompt");
}
#[test]
fn oversized_inline_images_are_refused_before_decoding() {
use std::io::Cursor;
let png = |w: u32, h: u32| {
let mut v = Vec::new();
image::RgbaImage::new(w, h).write_to(&mut Cursor::new(&mut v), image::ImageFormat::Png).unwrap();
v
};
let b64 = |d: &[u8]| {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(d)
};
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
// 8192x8192 is within the decoder's dimension limits but over the 32 Mpx budget.
t.feed(format!("\x1b]1337;File=inline=1:{}\x07", b64(&png(8192, 8192))).as_bytes());
assert!(t.images.is_empty());
t.feed(format!("\x1b]1337;File=inline=1:{}\x07", b64(&png(64, 64))).as_bytes());
assert_eq!(t.images.len(), 1);
}
#[test]
fn shell_integration_marks_survive_a_full_scrollback() {
// Scrollback of 30 lines, so the history fills and rotates during the test.
+19 -3
View File
@@ -129,8 +129,11 @@ pub fn group(n: u64) -> String {
/// GitHub API responses) are only handed to the system opener when they are http(s), so a
/// hostile or spoofed server can't launch `file:`, `ssh:` or custom-scheme handlers.
pub fn is_web_url(url: &str) -> bool {
let u = url.trim();
(u.len() > 8 && u[..8].eq_ignore_ascii_case("https://")) || (u.len() > 7 && u[..7].eq_ignore_ascii_case("http://"))
// Compare bytes: slicing the `str` at 7/8 panics when that offset falls inside a multi-byte
// character, and these strings come from the network (hovering a hostile OSC 8 link).
let u = url.trim().as_bytes();
let starts = |p: &[u8]| u.len() > p.len() && u[..p.len()].eq_ignore_ascii_case(p);
starts(b"https://") || starts(b"http://")
}
/// Opens `url` in the browser if it is a web link; returns false (and opens nothing) otherwise.
@@ -166,7 +169,20 @@ mod tests {
for ok in ["https://dixon.cx/downloads/nexus/", "http://localhost:3000/x", " HTTPS://Example.com "] {
assert!(super::is_web_url(ok), "{ok}");
}
for bad in ["", "https://", "file:///etc/passwd", "ssh://evil", "javascript:alert(1)", "ms-msdt:/id x", "ftp://host/", "//host/path"] {
assert!(super::is_web_url("http://über.example/"));
for bad in [
"",
"https://",
"file:///etc/passwd",
"ssh://evil",
"javascript:alert(1)",
"ms-msdt:/id x",
"ftp://host/",
"//host/path",
"mailto:ü@x",
"http:/üüü",
"ftp://é",
] {
assert!(!super::is_web_url(bad), "{bad}");
}
}