mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-09 12:04:09 +00:00
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:
1 parent
734eb11826
commit
e179d42418
5 files changed
+137
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
Reference in new issue
Block a user