Files
wordcraft/crates/doc/src/link.rs
T
85698c4d79 Security: pictures travel in the reply, exported links keep only safe targets (#678, #679) (#681)
- `ui.render` and `ui.screenshot` without a `path` return the PNG as base64 `png` in the
  reply instead of writing a fixed name into the shared temporary folder, where another
  user could have planted a link to one of this user's files; the MCP bridge's
  `render_page` and `screenshot` use that and no longer round-trip through
  `temp_dir()/wordcraft-mcp-*.png`. A given `path` still works as before (#678).
- One allow-list, `wordcraft_doc::link`: `is_web_or_mail_link` for Ctrl/⌘-click and
  `is_safe_href` (web, mail, `#bookmark`, relative path; schemes read the way browsers
  do, so `java\nscript:` is a script) for the HTML and Markdown exporters and the HTML
  importer. Scripts and other schemes export as plain text (#679).
- Tests: the allow-list, exporters with hostile links, the MCP bridge against a fake app
  (no `path` ever asked for), the control channel's in-reply PNGs.

Co-authored-by: petycomppety <30414255+petycomppety@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:57:08 -04:00

82 lines
3.3 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) 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.
pub fn is_safe_href(url: &str) -> bool {
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !url.trim().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",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
}
}
}