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
This commit is contained in:
aiongg authored and GitHub committed 2026-10-24 08:47:54 -04:00
1 parent 734eb11826
commit e179d42418
5 files changed
+137

No files matched your search

+6
View File
@@ -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
+2
View File
@@ -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;
+84
View File
@@ -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,<script>alert(1)</script>",
"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}");
}
}
}
+8
View File
@@ -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<backend::annotation::Target> {
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);
+37
View File
@@ -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,<script>alert(1)</script>",
"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::<Array<'_>>(b"Annots").unwrap();
let uris: Vec<String> = annotations
.iter::<Dict<'_>>()
.filter_map(|a| a.get::<Dict<'_>>(b"A")?.get::<PdfString<'_>>(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"]);
}