mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
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:
+27
-1
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user