diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 3df31cd119..8255daa453 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -31,6 +31,113 @@ pub fn for_command(id: &str) -> Option<&'static Link> { LINKS.iter().find(|l| l.command == id) } +/// The kinds of address a document may ask PdfCraft to open: web pages and email. Anything +/// else (`file:`, `javascript:`, `data:`, `smb:`, app handlers such as `ms-settings:`) is refused, +/// because the operating system would hand it to whichever program claims it (#90, #91). +pub const DOCUMENT_SCHEMES: &[&str] = &["https", "http", "mailto"]; + +/// The longest address a document may ask to open. A longer one is refused rather than shown +/// cut short in the confirmation, where the hidden tail could carry the document's form data. +pub const MAX_DOCUMENT_URL: usize = 2048; + +/// Why an address that came from a document was refused. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockedLink { + Empty, + /// Longer than [`MAX_DOCUMENT_URL`] characters. + TooLong(usize), + /// Control, zero-width or bidirectional-override characters, or whitespace other than a plain + /// space, which can hide part of the address or make it read as something else. + Hidden, + /// No scheme, or a web address without a host or with a space before its path. + Malformed, + /// A scheme other than [`DOCUMENT_SCHEMES`] (lowercased). + Scheme(String), +} + +impl std::fmt::Display for BlockedLink { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(f, "the address is empty"), + Self::TooLong(n) => write!(f, "the address is {n} characters long"), + Self::Hidden => write!(f, "the address contains hidden or invisible characters"), + Self::Malformed => write!(f, "the address isn't a complete web or email address"), + Self::Scheme(s) => write!(f, "it is a “{s}:” address"), + } + } +} + +/// Characters that can hide or disguise part of an address in the confirmation dialog. A plain +/// space is visible and turns up in real links (`mailto:…?subject=Order form`), so it is allowed. +fn hides_text(c: char) -> bool { + (c.is_whitespace() && c != ' ') + || c.is_control() + || matches!(c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}') +} + +/// Check an address that came from a document (a link, a button's URI action or a script's +/// `app.launchURL`) before PdfCraft offers to open it. Returns the address trimmed of +/// surrounding whitespace. PdfCraft's own links (Help, About, updates) don't come through here. +pub fn document_url(raw: &str) -> Result { + let url = raw.trim(); + if url.is_empty() { + return Err(BlockedLink::Empty); + } + let len = url.chars().count(); + if len > MAX_DOCUMENT_URL { + return Err(BlockedLink::TooLong(len)); + } + if url.chars().any(hides_text) { + return Err(BlockedLink::Hidden); + } + let (scheme, rest) = url.split_once(':').ok_or(BlockedLink::Malformed)?; + // RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ). + let well_formed = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); + if !well_formed { + return Err(BlockedLink::Malformed); + } + let scheme = scheme.to_ascii_lowercase(); + if !DOCUMENT_SCHEMES.contains(&scheme.as_str()) { + return Err(BlockedLink::Scheme(scheme)); + } + let complete = match scheme.as_str() { + "mailto" => !rest.is_empty(), + _ => authority(url).is_some_and(|a| !a.contains(' ')) && host(url).is_some(), + }; + if !complete { + return Err(BlockedLink::Malformed); + } + Ok(url.to_string()) +} + +/// The authority of a web address: what follows `//`, up to the first `/`, `\`, `?` or `#`. +/// Browsers read `\` as `/` in web addresses (the WHATWG URL standard), so it ends the authority +/// too; otherwise `https://other.example\@trusted.example/` would seem to go to +/// `trusted.example`. `None` for a non-web address or one without `//`. +fn authority(url: &str) -> Option<&str> { + let (scheme, rest) = url.split_once(':')?; + if !(scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http")) { + return None; + } + rest.strip_prefix("//")?.split(['/', '\\', '?', '#']).next() +} + +/// The host a web address really goes to, without any `user:password@` prefix, so +/// `https://trusted.example@other.example/` reports `other.example`. `None` for a non-web +/// address or one without a host. +pub fn host(url: &str) -> Option<&str> { + let authority = authority(url)?; + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let host = if host_port.starts_with('[') { + // An IPv6 literal keeps its brackets; only a port after them is dropped. + host_port.find(']').and_then(|i| host_port.get(..=i)).unwrap_or_default() + } else { + host_port.split(':').next().unwrap_or_default() + }; + (!host.is_empty()).then_some(host) +} + #[cfg(test)] mod tests { #[test] @@ -42,4 +149,78 @@ mod tests { assert!(crate::commands::command(l.command).is_some(), "{} is a registered command", l.command); } } + + #[test] + fn documents_may_offer_web_and_email_addresses() { + use super::document_url; + for ok in [ + "https://example.org", + "HTTPS://Example.org/a?b=c#d", + "http://example.org:8080/x", + "mailto:someone@example.org", + "https://[::1]:8443/", + // Plain spaces turn up in real documents' links and are visible in the dialog. + "mailto:orders@example.org?subject=Order form", + "https://example.org/My Report.pdf", + ] { + assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}"); + } + let multibyte = format!("https://example.org/{}", "é".repeat(super::MAX_DOCUMENT_URL - 20)); + assert!(document_url(&multibyte).is_ok(), "the limit counts characters, not bytes"); + assert_eq!(document_url(" https://example.org/ \n").as_deref(), Ok("https://example.org/"), "surrounding whitespace is trimmed"); + } + + #[test] + fn documents_may_not_offer_other_kinds_of_address() { + use super::{BlockedLink as B, MAX_DOCUMENT_URL, document_url}; + let scheme = |s: &str| Err(B::Scheme(s.into())); + assert_eq!(document_url("file:///C:/Windows/System32/calc.exe"), scheme("file")); + assert_eq!(document_url("FILE://server/share/x.exe"), scheme("file")); + assert_eq!(document_url("javascript:alert(1)"), scheme("javascript")); + assert_eq!(document_url("data:text/html,"), scheme("data")); + assert_eq!(document_url("ms-settings:privacy"), scheme("ms-settings")); + assert_eq!(document_url("smb://server/share"), scheme("smb")); + assert_eq!(document_url(""), Err(B::Empty)); + assert_eq!(document_url(" "), Err(B::Empty)); + for bad in [ + "example.org", + "//example.org", + "1http://x", + "ht tp://x", + "https:", + "https://", + "https:///path", + "https://user@/x", + "mailto:", + "https://trusted.example /x", + ] { + assert_eq!(document_url(bad), Err(B::Malformed), "{bad}"); + } + for hidden in [ + "https://exa\u{202E}gro.elpmaxe", + "https://example.org\u{200B}.evil", + "https://a.org/\u{0007}", + "https://a.org/x\ty", + "https://a.org/x\ny", + "https://example.org/a\u{00A0}b", + ] { + assert_eq!(document_url(hidden), Err(B::Hidden), "{hidden:?}"); + } + let long = format!("https://example.org/?q={}", "a".repeat(MAX_DOCUMENT_URL)); + assert!(matches!(document_url(&long), Err(B::TooLong(_)))); + } + + #[test] + fn the_host_shown_is_the_one_the_address_goes_to() { + use super::host; + assert_eq!(host("https://example.org/path"), Some("example.org")); + assert_eq!(host("https://trusted.example@other.example/"), Some("other.example")); + assert_eq!(host("http://user:pw@other.example:8080?x"), Some("other.example")); + assert_eq!(host("https://[::1]:8443/"), Some("[::1]")); + // Browsers read `\` as `/`, so this goes to other.example, not trusted.example. + assert_eq!(host("https://other.example\\@trusted.example/"), Some("other.example")); + assert_eq!(host("https://other.example\\x@trusted.example/"), Some("other.example")); + assert_eq!(host("mailto:a@example.org"), None); + assert_eq!(host("https:///nohost"), None); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e939c2a5c7..c9b64b75fd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1600,7 +1600,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } match clicked_link { Some(LinkTarget::Page(p)) => view.go_to_page(p), - Some(LinkTarget::Uri(u)) => ui.ctx().open_url(egui::OpenUrl::new_tab(u)), + Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), Some(LinkTarget::Other(s)) => app.notify(format!("{s} actions run in the JavaScript engine (M6)")), None => {} } @@ -1687,13 +1687,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.notify(n); } if let Some((name, action)) = app.views[index].forms.button.take() { - run_button(app, index, ui.ctx(), &name, action); + run_button(app, index, &name, action); } quick_bar(app, avail, ui); } /// Run a push button's action (the ones that need no JavaScript engine). -fn run_button(app: &mut PdfCraftApp, index: usize, ctx: &egui::Context, name: &str, action: pdfcraft_engine::form_scripts::ButtonAction) { +fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_engine::form_scripts::ButtonAction) { use pdfcraft_engine::form_scripts::ButtonAction as B; let pages = app.session.get(app.views[index].id).map_or(0, |d| d.info.pages.len()); match action { @@ -1716,7 +1716,7 @@ fn run_button(app: &mut PdfCraftApp, index: usize, ctx: &egui::Context, name: &s "LastPage" => app.views[index].go_to_page(pages.saturating_sub(1)), other => app.notify(format!("{name}: the {other} action isn't supported yet")), }, - B::Uri(u) => ctx.open_url(egui::OpenUrl::new_tab(u)), + B::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Button), B::GoTo(p) => app.views[index].go_to_page(p.min(pages.saturating_sub(1))), B::Alert(m) => app.notify(m), B::Submit(url) => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e47853ec3f..414611f75a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -12,6 +12,7 @@ const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); + link_prompt(app, ctx); crate::updates::dialog(app, ctx); let Some(dialog) = app.dialog else { app.props_draft = None; @@ -1315,6 +1316,57 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { } } +/// "Open this web page?" when a document's link, button or script asks to open an address +/// (#90, #91). Shows where the address really goes and the whole address; Cancel is the default, +/// and Escape or clicking outside cancels. +fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { + let Some(pending) = app.pending_link.clone() else { return }; + let t = Tokens::get(ctx); + let email = pending.url.get(..7).is_some_and(|s| s.eq_ignore_ascii_case("mailto:")); + let (title, open) = if email { ("Write this email?", "Open email app") } else { ("Open this web page?", "Open link") }; + let mut choice: Option = None; + let modal = egui::Modal::new(egui::Id::new("link_prompt")).show(ctx, |ui| { + ui.set_width(460.0); + ui.horizontal(|ui| { + ui.add(crate::icons::image("external-link", 22.0, t.accent)); + ui.label(egui::RichText::new(title).font(theme::semibold(16.0))); + }); + ui.add_space(6.0); + let what = if email { "your email app with this address" } else { "this address in your browser" }; + ui.label(format!("{} in this document wants to open {what}:", pending.origin.noun())); + ui.add_space(6.0); + if let Some(host) = pdfcraft_engine::links::host(&pending.url) { + ui.label(egui::RichText::new(host).font(theme::semibold(14.0))); + } + egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(&pending.url).monospace().small()).wrap().selectable(true)); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new( + "Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form.", + ) + .color(t.text_muted) + .small(), + ); + ui.add_space(14.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Cancel", true).clicked() { + choice = Some(false); + } + if widgets::pill_button(ui, open, false).clicked() { + choice = Some(true); + } + }); + }); + if choice.is_none() && modal.should_close() { + choice = Some(false); + } + if let Some(open) = choice { + app.resolve_pending_link(open); + } +} + fn yes(b: bool) -> String { if b { "Yes".into() } else { "No".into() } } diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 1e0f1e27ca..1c58931630 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -24,8 +24,8 @@ pub struct DocJsDraft { impl PdfCraftApp { /// Act on what scripts produced in document `id`: alerts are shown, console output goes to - /// the console, and print / page / link requests are carried out (form submissions are - /// reported, never sent). + /// the console, print and page requests are carried out, links wait for the user's permission + /// (form submissions are reported, never sent). pub fn handle_js(&mut self, id: DocId, out: JsOutput) { if out.is_empty() { return; @@ -43,11 +43,7 @@ impl PdfCraftApp { self.views[i].go_to_page(p); } } - Request::LaunchUrl(u) => { - if let Some(ctx) = &self.ctx { - ctx.open_url(egui::OpenUrl::new_tab(u)); - } - } + Request::LaunchUrl(u) => self.request_document_url(&u, crate::LinkOrigin::Script), Request::Submit(u) => self.notify(format!( "The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries." )), @@ -251,7 +247,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok app.session.set_javascript(on); } ui.label( - egui::RichText::new("Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.") + egui::RichText::new("Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.") .small() .color(t.text_muted), ); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d8c14de2d..e1c533c43b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -440,6 +440,36 @@ pub struct PdfCraftApp { pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, + /// A document asked to open this address; the user hasn't answered yet (#90, #91). + pub pending_link: Option, +} + +/// Where in a document a request to open an address came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkOrigin { + /// A link on the page. + Link, + /// A push button's URI action. + Button, + /// A script (`app.launchURL`). + Script, +} + +impl LinkOrigin { + fn noun(self) -> &'static str { + match self { + Self::Link => "A link", + Self::Button => "A button", + Self::Script => "A script", + } + } +} + +/// An address a document asked to open, waiting for the user to allow or cancel it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingLink { + pub url: String, + pub origin: LinkOrigin, } /// Settings for the Number pages dialog. @@ -543,6 +573,7 @@ impl PdfCraftApp { control: None, bookmark_rename: None, last_opened_url: None, + pending_link: None, protect_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), @@ -799,7 +830,8 @@ impl PdfCraftApp { client } - /// Open a web link in the system browser (a new tab on the web). + /// Open a web link in the system browser (a new tab on the web). Only for PdfCraft's own + /// links; an address that came from a document goes through [`Self::request_document_url`]. pub fn open_url(&mut self, url: &str) { if let Some(ctx) = &self.ctx { ctx.open_url(egui::OpenUrl::new_tab(url)); @@ -807,6 +839,33 @@ impl PdfCraftApp { self.last_opened_url = Some(url.to_string()); } + /// A document asks to open `url` (a link, a button's URI action or `app.launchURL`). Web and + /// email addresses wait for the user to allow them; anything else is refused with a notice + /// (#90, #91). While one request is waiting, further ones are dropped, so a script can't + /// queue up a stream of dialogs. + pub fn request_document_url(&mut self, url: &str, origin: LinkOrigin) { + match pdfcraft_engine::links::document_url(url) { + Ok(url) => { + if self.pending_link.is_none() { + self.pending_link = Some(PendingLink { url, origin }); + } + } + Err(e) => self.notify(format!( + "{} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents.", + origin.noun() + )), + } + } + + /// The user's answer to [`Self::pending_link`]: open it, or drop it. + pub fn resolve_pending_link(&mut self, open: bool) { + if let Some(p) = self.pending_link.take() + && open + { + self.open_url(&p.url); + } + } + pub fn notify(&mut self, msg: impl Into) { self.toast = Some((msg.into(), 0.0)); } diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs new file mode 100644 index 0000000000..b7857ba8a0 --- /dev/null +++ b/crates/ui-egui/tests/document_links.rs @@ -0,0 +1,169 @@ +//! Addresses a document asks to open (links, button URI actions, `app.launchURL`) wait for the +//! user's permission, and anything but a web or email address is refused (#90, #91). + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::{LinkOrigin, PdfCraftApp, PendingLink}; + +/// See `tests/view.rs`: WARP's shader JIT crashes when two devices compile at once on ARM64. +static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn gpu() -> std::sync::MutexGuard<'static, ()> { + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +const LINK_URL: &str = "https://example.org/from-a-link"; +const BUTTON_URL: &str = "https://example.org/from-a-button"; + +/// One 300×400 page with a web link (x 50..250, y 300..350), a push button with a URI action +/// ("web") and a push button whose JavaScript asks to open a local program ("sneaky"). +fn fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".to_string(), + format!("<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI ({LINK_URL}) >> >>"), + format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (web) /Rect [20 200 100 220] /P 3 0 R /A << /S /URI /URI ({BUTTON_URL}) >> >>"), + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (sneaky) /Rect [120 200 200 220] /P 3 0 R /AA << /U << /S /JavaScript /JS (app.launchURL\\(\"file:///C:/Windows/System32/calc.exe\", true\\);) >> >> >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("links.pdf", None, fixture()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + h +} + +fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) { + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); + h.drop_at(at); + h.run_steps(4); +} + +/// Click the link on page 1, at the middle of its rectangle. +fn click_link(h: &mut Harness<'static, PdfCraftApp>) { + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + click_at(h, at); +} + +fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let at = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center() + }; + click_at(h, at); +} + +fn pending(url: &str, origin: LinkOrigin) -> Option { + Some(PendingLink { url: url.into(), origin }) +} + +#[test] +fn a_link_asks_first_and_opens_when_allowed() { + let _gpu = gpu(); + let mut h = harness(); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers"); + h.get_by_label("example.org"); + h.get_by_label("Open link").click(); + h.run_steps(3); + assert_eq!(h.state().last_opened_url.as_deref(), Some(LINK_URL)); + assert_eq!(h.state().pending_link, None); +} + +#[test] +fn cancel_opens_nothing() { + let _gpu = gpu(); + let mut h = harness(); + click_link(&mut h); + assert!(h.state().pending_link.is_some()); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn escape_cancels() { + let _gpu = gpu(); + let mut h = harness(); + click_link(&mut h); + assert!(h.state().pending_link.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().pending_link, None); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn a_button_uri_action_asks_first() { + let _gpu = gpu(); + let mut h = harness(); + click_field(&mut h, "web"); + assert_eq!(h.state().pending_link, pending(BUTTON_URL, LinkOrigin::Button)); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn a_button_script_cannot_open_a_local_program() { + let _gpu = gpu(); + let mut h = harness(); + click_field(&mut h, "sneaky"); + assert_eq!(h.state().pending_link, None, "a file: address is never offered"); + assert_eq!(h.state().last_opened_url, None); + let toast = h.state().toast.clone().expect("the user is told").0; + assert!(toast.contains("“file:” address"), "{toast}"); +} + +#[test] +fn a_script_asks_first_and_only_once() { + let _gpu = gpu(); + let mut h = harness(); + let id = h.state().views[0].id; + let script = "app.launchURL('https://example.org/?q=' + 'first'); app.launchURL('https://example.org/second');"; + h.state_mut().run_button_script(id, "web", script); + h.run_steps(3); + assert_eq!(h.state().pending_link, pending("https://example.org/?q=first", LinkOrigin::Script), "one dialog, for the first request"); + assert_eq!(h.state().last_opened_url, None); +} + +#[test] +fn a_script_cannot_open_other_kinds_of_address() { + let _gpu = gpu(); + for url in ["file:///etc/passwd", "javascript:alert(1)", "ms-settings:privacy", "smb://server/share", "https://exa\u{202E}gro.elpmaxe"] { + let mut h = harness(); + let id = h.state().views[0].id; + let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap()); + h.state_mut().run_button_script(id, "web", &script); + h.run_steps(3); + assert_eq!(h.state().pending_link, None, "{url}"); + assert_eq!(h.state().last_opened_url, None, "{url}"); + assert!(h.state().toast.clone().is_some_and(|t| t.0.contains("won't open")), "{url}: {:?}", h.state().toast); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fcf3d75a60..bd6710b106 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2202,8 +2202,8 @@ milestone = "M7" status = "shipped" commands = ["edit.link"] tools = ["link_add"] -evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools"] -notes = "" +evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first"] +notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91)." [[feature]] id = "edit.action-launch" @@ -5094,8 +5094,8 @@ milestone = "M6" status = "shipped" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"] -notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits." +evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/document_links.rs::a_script_cannot_open_other_kinds_of_address"] +notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)." [[feature]] id = "form.folder-level-js"