diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 657a7fda..8e654138 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -119,6 +119,12 @@ serialization uses the same backend version as before extraction. It provides: postprocessing. App errors pass through; serialization errors retain backend diagnostics. Caught panic messages retain up to 1024 Unicode scalar values; the installed panic hook remains responsible for logging source locations and full diagnostics. +- `write::uri_target`: the Krilla link target for a URI link, or `None` when `safe_url` + refuses it. Apps build URI links with it instead of `backend::action::LinkAction`, so a + link target from someone else's document becomes a PDF link only when it is an `http`/`https` + URL with a host or a `mailto` address. A refused target writes no link annotation. +- `safe_url` (crate root, any feature): the same rule, for apps that open links themselves, + such as a slide show. ### Print color diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e540157a..9814c469 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -6,8 +6,10 @@ #[cfg(feature = "interpret")] pub use hayro_interpret as interpret; +mod link; #[cfg(feature = "read")] pub use hayro_syntax as syntax; +pub use link::safe_url; #[cfg(feature = "read")] mod read; diff --git a/crates/pdf/src/link.rs b/crates/pdf/src/link.rs new file mode 100644 index 00000000..f4c7b6a7 --- /dev/null +++ b/crates/pdf/src/link.rs @@ -0,0 +1,84 @@ +//! Which link targets from a document may become clickable links in a written PDF. + +/// Whether a link target from a (possibly untrusted) document may be opened by whoever opens +/// the file: `http`/`https` URLs with a host, and `mailto` with an address. +/// +/// Everything else is refused: `file:`, `javascript:`, `data:`, relative paths, network +/// shares (`\\host\share`, `//host/share`, `smb:`) and other schemes, which an operating system +/// may hand to a protocol handler. Leading and trailing whitespace is ignored; schemes compare +/// case-insensitively. +pub fn safe_url(url: &str) -> bool { + let u = url.trim(); + let Some((scheme, rest)) = u.split_once(':') else { return false }; + match scheme.to_ascii_lowercase().as_str() { + "http" | "https" => rest.strip_prefix("//").is_some_and(|r| !host(r).is_empty()), + "mailto" => has_recipient(rest), + _ => false, + } +} + +/// The host of an authority-first URL remainder: before the path, query or fragment, after any +/// userinfo, without the port. +fn host(rest: &str) -> &str { + let authority = rest.split(['/', '?', '#', '\\']).next().unwrap_or(""); + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + match host_port.strip_prefix('[') { + Some(v6) => v6.split_once(']').map_or("", |(h, _)| h), + None => host_port.split(':').next().unwrap_or(""), + } +} + +/// Whether a `mailto:` remainder names an address, before its headers or in a `to` header. +fn has_recipient(rest: &str) -> bool { + let (to, headers) = rest.split_once('?').unwrap_or((rest, "")); + !to.trim().is_empty() || headers.split('&').any(|h| h.split_once('=').is_some_and(|(k, v)| k.eq_ignore_ascii_case("to") && !v.trim().is_empty())) +} + +#[cfg(test)] +mod tests { + use super::safe_url; + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn only_web_and_mail_links_are_safe_to_open() { + for url in [ + "https://example.com", + " HTTP://example.com/x", + "https://user@example.com:8443/x?q#f", + "https://[::1]/", + "mailto:someone@example.com", + "mailto:?to=someone@example.com&subject=hi", + ] { + assert!(safe_url(url), "{url}"); + } + let refused = [ + "https://", + "https://?q=x", + "https:///x", + "https://user@/x", + "https://:80/", + "mailto:", + "mailto:?subject=hello", + "file:///C:/notes.pdf", + "notes.pdf", + "javascript:alert(1)", + "\t JavaScript:alert(1)", + "java\nscript:alert(1)", + "vbscript:MsgBox(1)", + "data:text/html,", + "ms-msdt:/id PCWDiagnostic", + "ms-word:ofe|u|\\\\attacker.example\\share\\x.docx", + "ldap://attacker.example/x", + "//attacker.example/share", + "\\\\attacker.example\\share", + "file://attacker.example/share/x", + "file:////attacker.example/share/x", + "smb://attacker.example/share", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + "search:query=x&crumb=location:\\\\attacker.example\\share", + ]; + for url in refused { + assert!(!safe_url(url), "{url}"); + } + } +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 3731a99b..057e1559 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -139,6 +139,14 @@ impl Writer { } } +/// The link target that opens `url`, or `None` when [`safe_url`](crate::safe_url) refuses it. +/// Build URI links with this instead of `backend::action::LinkAction` so a refused target +/// writes no link annotation; the page content stays as drawn. The URI is written trimmed. +pub fn uri_target(url: &str) -> Option { + let url = url.trim(); + crate::safe_url(url).then(|| backend::annotation::Target::Action(backend::action::LinkAction::new(url.to_owned()).into())) +} + fn check_font_input(size: usize, variations: Option<&[([u8; 4], f32)]>) -> Result<(), WriteError> { if size > MAX_FONT_BYTES { return Err(WriteError::FontLimit); diff --git a/crates/pdf/tests/writing.rs b/crates/pdf/tests/writing.rs index da611b61..9d4e0fe9 100644 --- a/crates/pdf/tests/writing.rs +++ b/crates/pdf/tests/writing.rs @@ -317,3 +317,40 @@ fn empty_language_is_absent_and_keeps_required_language_validation() { } assert_eq!(outputs[0], outputs[1], "empty language and absent language must serialize identically"); } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn only_web_and_mail_link_targets_become_uri_links() { + use backend::annotation::{Annotation, LinkAnnotation}; + use hayro_syntax::object::{Array, Dict, String as PdfString}; + let urls = [ + " https://example.com/deck ", + "mailto:someone@example.com", + "javascript:alert(1)", + "data:text/html,", + "file://attacker.example/share/x", + "\\\\attacker.example\\share\\x", + "//attacker.example/share/x", + "ms-msdt:/id PCWDiagnostic", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + ]; + let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, None).unwrap(), ..Default::default() }; + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 400.0).unwrap())); + for (i, url) in (0u8..).zip(urls) { + let rect = Rect::from_xywh(10.0, 10.0 + 30.0 * f32::from(i), 100.0, 20.0).unwrap(); + if let Some(target) = write::uri_target(url) { + page.add_annotation(Annotation::new_link(LinkAnnotation::new(rect, target), None)); + } + } + page.finish(); + let bytes = writer.finish().unwrap(); + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes)).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, ["https://example.com/deck", "mailto:someone@example.com"]); +}