Files
figcraft/crates/engine/src/doc_urls.rs
T
skylar taylor-barrick 37419afe14 Open only web links and local libraries from a document
A document carries things the app acts on for whoever opens it. Three of them were
taken as given.

Prototype Open link and Dev Mode dev resources both reached the platform's opener
with any scheme. In the web build the opener is window.open, so a saved document
could run script in the app's own origin; on the desktop any scheme went to the
system's handler. Both now pass through the new engine module `doc_urls`, which
allows http, https and mailto and nothing else. It is an allow-list on purpose: a
list of known-bad schemes is outgrown by every handler a platform adds.

A figcraft:// invite link joined the session at once. The system hands the app
whatever such link was followed, which may come from a page or a message, so the
Join dialog now opens with the link filled in and the person confirms.

A library link stored in a document was loaded from wherever it pointed. Only a
path on this computer is followed now, so opening someone else's file cannot make
the app contact their server (which on Windows would also send credentials).

Tests: each change has one that fails without it, verified by reverting. The
prototype test fires through an afterDelay trigger, since no click is needed, and
keeps a positive case so a refusal cannot pass vacuously.
2026-10-17 13:19:13 -06:00

49 lines
2.5 KiB
Rust

//! Which URLs stored in a document may be handed to the browser or the system opener.
//!
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
//! up at the platform's opener, so a document that someone else made chooses what that opener is
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
//!
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
//! the app's own origin, so a saved document becomes stored XSS.
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
//! schemes to whichever application registered them.
//!
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
//! dangerous schemes is outgrown by every new handler a platform adds.
/// The schemes a URL stored in a document may use.
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
/// Whether `url`, stored in a document, may be opened.
///
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
/// refused: the opener would resolve it against the app's own page on the web. Control characters
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
pub fn openable(url: &str) -> bool {
let url = url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = url.split_once(':') else { return false };
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return false;
}
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
return false;
}
if scheme.eq_ignore_ascii_case("mailto") {
// `mailto:` names an address.
return !rest.is_empty();
}
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
// and `http:///p` has no host.
let Some(after) = rest.strip_prefix("//") else { return false };
!after.is_empty() && !after.starts_with('/')
}