Files
wordcraft/crates/doc/src/link.rs
T
Jeremy YaoandClaude Fable 5.1 c3bdcd73b4 Harden against hostile-document DoS and non-web link export (#1235)
* Harden against hostile-document DoS and non-web link export

A security pass found several small files that make the app allocate or
compute without bound when opened (GUI, wordcraft-cli, or the MCP server),
and link targets leaving on export without the scheme check the HTML and
Markdown writers already apply. None lets a document run code or read files;
all are denial-of-service or credential-leak-on-click. Each fix bounds an
attacker-controlled size and lands with a regression test, per the
never-crash standard.

- layout: a table repeats its header rows on every page it spills onto, so
  many tall header rows paginate to O(headers x pages) placed items. Cap the
  total header-row repeats per table (lib.rs). 2 KB docx: 1.1 GB -> 124 MB.
- formats/model, html: an HTML table of many <tr> with large rowspan expanded
  to rows x 64 covered cells, and `model::table` pre-allocated a full
  rows x cols grid only to clear it. Cap rows at MAX_ROWS before
  insert_covered, bound its total work, and build the grid template with one
  row. 640 KB html: >1.5 GB -> 214 MB (a plain large table is faster too).
- docx/read/freeform: a custom-geometry Bezier segment took any number of
  points, making de Casteljau flattening quadratic. Cap points per segment.
  3 KB docx: 12 s -> 0.15 s.
- docbin: footnote/header/text-box story tables and the piece table can name
  the same backing text many times. Hold the total characters materialised to
  twice the WordDocument stream size (a ceiling no real file reaches, since
  each char costs >=1 stream byte). 45 KB doc: >1.2 GB -> 14 MB.
- render, pdf, formats/rtf: document pictures were decoded with no dimension
  limit, so a tiny file declaring a huge image could allocate before the size
  check. Decode through image::Limits with a side cap first.
- doc/link, formats/rtf, odt, latex: route exported hyperlink targets through
  is_safe_href (as HTML/Markdown already do), and extend it to reject
  protocol-relative (//host) and UNC (\host) references. Keeps file:,
  javascript:, ms-msdt: and UNC targets from being written into exported RTF,
  ODT, LaTeX and PDF where a reader would follow them (UNC leaks NTLM on
  Windows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align picture-decode limits with the bounds already in force

Review of the previous commit: `image::load_from_memory` and
`load_from_memory_with_format` already applied `Limits::default()`, whose
512 MB allocation cap bounded every picture decode. The PDF change had set
`max_alloc = None`, loosening that; restore the default. The render and RTF
side caps (16 384 px) newly rejected wide-but-short pictures that fit in
memory, so render now uses the pixmap's own `u16` side limit (what
`premul_pixmap` enforced after decoding, applied before it instead) and the
RTF re-encode keeps only the explicit allocation cap. Net effect: rejection
moves to header time with no change to the sizes accepted. Also fix a
comment: the header-row repeat cap is per table, not per document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 18:00:35 -04:00

96 lines
4.1 KiB
Rust

//! Which hyperlink targets may leave the document: followed by the OS, or written into an
//! exported HTML/Markdown file where a browser would run them.
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
/// space or a control character makes it not a link.
pub fn is_web_or_mail_link(url: &str) -> bool {
let Some((scheme, _)) = url.split_once(':') else { return false };
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
return false;
}
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !cleaned.is_empty(),
}
}
/// The scheme a browser would see in `url` (RFC 3986 §3.1: a letter, then letters, digits, `+`,
/// `-` or `.`, up to the first `:`), lower-cased; `None` for a relative reference.
fn scheme(url: &str) -> Option<String> {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
let (s, _) = cleaned.split_once(':')?;
let mut chars = s.chars();
let first = chars.next()?;
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_web_and_mail_links_are_followable() {
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
assert!(is_web_or_mail_link(ok), "{ok}");
}
for bad in [
"file:///etc/passwd",
"C:\\Windows\\System32\\calc.exe",
"/usr/bin/xterm",
"javascript:alert(1)",
"ms-msdt:/id",
" https://example.com",
"https\u{0}://example.com",
"",
"example.com",
"#top",
] {
assert!(!is_web_or_mail_link(bad), "{bad:?}");
}
}
/// An exported `href` may be a web/mail link, a bookmark or a relative path: never a script
/// or another scheme, however a browser would normalise it.
#[test]
fn exported_hrefs_allow_bookmarks_and_relative_paths_but_no_scripts() {
for ok in ["https://example.com/x", "mailto:a@b.c", "#top", "other.html", "docs/guide.html#s2", "../up.html", "?q=1"] {
assert!(is_safe_href(ok), "{ok}");
}
for bad in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"java\nscript:alert(1)",
"\tjavascript:alert(1)",
" javascript:alert(1)",
"java\u{0}script:alert(1)",
"vbscript:MsgBox(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
"//attacker.example/share/x",
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
}
}
}