From e179d424186122e4089cdeb9f0d45c9bd7fa6990 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Sat, 24 Oct 2026 08:47:54 -0400
Subject: [PATCH] craft-pdf: write URI links only for http(s) with a host and
mailto (#282)
* craft-pdf: write URI links only for http(s) with a host and mailto
A document from someone else can carry any link target, and an export
that turns every hyperlink into a PDF URI action hands those targets to
whoever opens the file: script and data URIs, OS protocol handlers,
UNC paths, //host/share, file:// and smb:// links to a host, and
Windows Search links naming a share.
safe_url allows only http and https URLs with a host and mailto with an
address, after trimming whitespace and comparing the scheme
case-insensitively. It is the rule DeckCraft uses for its slide show
and export, and is available without any feature.
write::uri_target returns the Krilla link target for an allowed URL and
None otherwise, so an app that builds its URI links with it writes no
link annotation for a refused target and leaves the page content as
drawn.
* craft-pdf: safe_url needs a real http(s) host and a mailto recipient
---
crates/pdf/README.md | 6 +++
crates/pdf/src/lib.rs | 2 +
crates/pdf/src/link.rs | 84 +++++++++++++++++++++++++++++++++++++
crates/pdf/src/write.rs | 8 ++++
crates/pdf/tests/writing.rs | 37 ++++++++++++++++
5 files changed, 137 insertions(+)
create mode 100644 crates/pdf/src/link.rs
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"]);
+}