diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 40d09c71..6d3f5f42 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -565,7 +565,7 @@ impl Exporter<'_> { slide.and_then(|s| page_of.get(&s)).map(|p| Target::Destination(XyzDestination::new(*p, Point::from_xy(0.0, 0.0)).into())) }; let target = match &l.link.action { - ClickAction::Url { url } => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))), + ClickAction::Url { url } if exportable_url(url) => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))), ClickAction::Slide { slide } => goto(pres.slides.iter().position(|x| x.id == *slide)), ClickAction::NextSlide => goto(Some(i + 1)), ClickAction::PreviousSlide => goto(i.checked_sub(1)), @@ -693,5 +693,31 @@ fn to_path(bp: &kurbo::BezPath) -> Option { pb.finish() } +/// Can a hyperlink from a (possibly untrusted) deck become a PDF link? Scripts (`javascript:`, +/// `vbscript:`), `data:` and paths to another machine (`\\host\share`, `//host/share`, +/// `file://host/...`) cannot: on Windows, opening a network path sends the user's NTLM credentials +/// to that host. Web, mail, local `file:` and relative links can. Read the way a browser reads a +/// URL: control characters dropped, leading spaces trimmed, any case. +fn exportable_url(url: &str) -> bool { + let u: String = url.chars().filter(|c| !c.is_ascii_control()).collect(); + let u = u.trim_start().replace('\\', "/"); + if u.starts_with("//") { + return false; + } + let Some((scheme, rest)) = u.split_once(':') else { return true }; + match scheme.to_ascii_lowercase().as_str() { + "javascript" | "vbscript" | "data" => false, + "file" => match rest.strip_prefix("//") { + // `file:///C:/...` and `file://localhost/...` are local; `file://host/...` and `file:////host/...` are not. + Some(p) => { + let (host, path) = p.split_once('/').unwrap_or((p, "")); + (host.is_empty() || host.eq_ignore_ascii_case("localhost")) && !path.starts_with('/') + } + None => true, + }, + _ => true, + } +} + #[cfg(test)] mod tests; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index a17d461e..ff349b4b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -130,6 +130,55 @@ fn text_layer_links_and_bookmarks_survive_serialization() { } } +/// Links from an untrusted deck: scripts, `data:` and network shares (a UNC path leaks the user's +/// NTLM credentials on Windows) do not become PDF links; web, mail, local and relative links do. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn unsafe_url_links_are_not_exported() { + use deckcraft_model::text::{Action, Hyperlink}; + use deckcraft_model::{Shape, ShapeId, Xfrm}; + use hayro_syntax::object::{Array, Dict, String as PdfString}; + let safe = ["https://example.com/deck", "mailto:someone@example.com", "file:///C:/talks/notes.pdf", "notes.pdf"]; + let refused = [ + "javascript:alert(1)", + "\t JavaScript:alert(1)", + "java\nscript:alert(1)", + "vbscript:MsgBox(1)", + "data:text/html,", + "\\\\attacker.example\\share\\x", + "//attacker.example/share/x", + "file://attacker.example/share/x", + "file:////attacker.example/share/x", + ]; + let mut p = deck(1); + let slide = Arc::make_mut(&mut p.slides[0]); + slide.shapes = (0..) + .zip(safe.iter().chain(&refused)) + .map(|(i, url)| Shape { + id: ShapeId(9000 + i), + xfrm: Some(Xfrm::new(20.0, 20.0 + 38.0 * f64::from(i), 400.0, 30.0)), + click: Some(Hyperlink { + action: Action::Url { url: url.to_string() }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }), + ..Default::default() + }) + .collect(); + let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let annotations = pdf.pages()[0].raw().get::>(b"Annots").unwrap(); + let uris: Vec = annotations + .iter::>() + .filter_map(|a| a.get::>(b"A")?.get::>(b"URI")) + .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) + .collect(); + assert_eq!(uris, safe); +} + /// Build a small damaged derivative of our openly licensed last-resort font entirely in /// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF /// embedding. No font bytes or vendor assets are stored as test fixtures.