Security: ask before opening links from documents (#90, #91) (#104)

A document could open any address with no confirmation: a link click, a
push button's URI action, a one-line button script mapped to a URI action,
or app.launchURL from JavaScript (on by default). That allowed sending
form data to an outside site, or handing file:, smb: and app-handler
addresses to the operating system.

All four now go through PdfCraftApp::request_document_url:

- engine::links::document_url accepts only http, https and mailto, up to
  2048 characters, and refuses control, zero-width, bidi-override and
  non-space whitespace characters, malformed schemes and web addresses
  without a host.
- Accepted addresses wait in an "Open this web page?" dialog that shows
  the real host (engine::links::host drops user:password@, and treats \
  like / as browsers do, so https://a.example\@b.example shows a.example)
  and the full address. Cancel is the highlighted button; Escape and
  clicking outside cancel. While one request waits, further ones are
  dropped, so a script can't stack dialogs.
- Refused addresses are reported in a notice and never opened.

PdfCraft's own links (Help, About, updates) still use open_url.
Preferences > JavaScript now says scripts need permission to open a web
page. Tests: engine unit tests for the policy and host parsing, and
crates/ui-egui/tests/document_links.rs driving the link, button, button
script, app.launchURL and Escape paths (all fail against the old code).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jcl-bit
2026-10-07 21:12:36 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 70da0179a6
commit cc7694be99
7 changed files with 474 additions and 17 deletions
+181
View File
@@ -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<String, BlockedLink> {
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,<script>x</script>"), 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);
}
}
+4 -4
View File
@@ -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) => {
+52
View File
@@ -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<bool> = 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() }
}
+4 -8
View File
@@ -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),
);
+60 -1
View File
@@ -440,6 +440,36 @@ pub struct PdfCraftApp {
pub replace_draft: Option<files::ReplaceDraft>,
/// The last web link the app asked the system to open (tests and automation).
pub last_opened_url: Option<String>,
/// A document asked to open this address; the user hasn't answered yet (#90, #91).
pub pending_link: Option<PendingLink>,
}
/// 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<String>) {
self.toast = Some((msg.into(), 0.0));
}
+169
View File
@@ -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<u8> {
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<PendingLink> {
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);
}
}
+4 -4
View File
@@ -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"