PDF export drops javascript:, vbscript:, data: and network-share links

A slide hyperlink from an untrusted deck became a PDF link whatever it
pointed at. Script and data links, and links to another machine (UNC
\\host\share, //host/share, file://host/...), are now left out of the
PDF; the text stays. On Windows, opening a network path sends the
user's NTLM credentials to that host.

Web, mail, local file: and relative links are kept. The scheme is read
the way a browser reads it: control characters dropped, leading spaces
trimmed, any case. The slide show already refuses these links
(safe_url), and cadcraft's PDF export drops script and data links.
This commit is contained in:
oracola91
2026-10-24 13:27:00 +03:00
parent 7be391a1be
commit 8fd1c1716f
2 changed files with 76 additions and 1 deletions
+27 -1
View File
@@ -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<krilla::geom::Path> {
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;
+49
View File
@@ -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,<script>alert(1)</script>",
"\\\\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::<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, 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.