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:
+1
-1
@@ -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
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user