Skip to main content

ravenrs_sheet/engine/
links.rs

1//! Which hyperlink targets may be opened. A cell's link comes from the document (often a file
2//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
3//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
4//! the like are refused. Everything without a scheme stays an in-workbook reference.
5//!
6//! Opening an allowed address also needs the user's say-so (#2181): `link.open` is reachable by
7//! the control channel, the CLI, MCP and workbook scripts, so a prompt-injected agent could open
8//! `https://attacker/?d=<cell data>`. Under the default [`LinkPolicy::Confirm`] the command refuses
9//! external addresses; only the host, after showing the address and getting OK from the user,
10//! opens one through [`crate::engine::Session::open_confirmed_link`], which no command reaches.
11
12use crate::base::RangeRef;
13use serde_json::{Value as Json, json};
14
15use crate::engine::{EngineError, Result, Session, UiRequest};
16
17/// Whether `link.open` may hand an external (`http`/`https`/`mailto`) address to the host without
18/// asking. Set by the host in code, never by a command, so a programmatic caller can't widen it.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum LinkPolicy {
21    /// `link.open` refuses external addresses; the user opens them by clicking the link, which
22    /// asks first ([`Session::open_confirmed_link`]). References still navigate.
23    #[default]
24    Confirm,
25    /// The host trusts every caller of this session (an embedding that vets requests itself):
26    /// `link.open` opens allowed addresses directly. Disallowed schemes stay refused.
27    Allow,
28}
29
30/// The error a programmatic `link.open` of a web or e-mail address returns.
31pub const NEEDS_CONFIRMATION: &str = "Web and e-mail links open only when the user clicks them and confirms; a command can't open them.";
32
33impl Session {
34    /// Opens a link the user has just confirmed in the host's "Open Hyperlink" prompt: an allowed
35    /// address goes to the host (`UiRequest::OpenUrl`), a reference selects, anything else is
36    /// refused ([`classify`]). Not a command: only host code that showed the user this exact
37    /// address may call it.
38    pub fn open_confirmed_link(&mut self, target: &str) -> Result<Json> {
39        match classify(target) {
40            LinkKind::Internal(r) => self.execute("edit.goTo", json!({"reference": r.trim_start_matches('#')})),
41            LinkKind::External(u) => {
42                self.ui_requests.push(UiRequest::OpenUrl(u.clone()));
43                Ok(json!({"opened": u}))
44            }
45            LinkKind::Refused(why) => Err(EngineError::Other(why)),
46        }
47    }
48}
49
50/// What a link target is.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum LinkKind {
53    /// A cell, range, name or table of the workbook (`Sheet2!A1`, `Total`).
54    Internal(String),
55    /// An address to hand to the system, trimmed: `http`, `https` or `mailto` only.
56    External(String),
57    /// Refused, with the reason to show.
58    Refused(String),
59}
60
61const ALLOWED: [&str; 3] = ["http", "https", "mailto"];
62
63/// Classifies a link target. Never panics; the single place that decides what may be opened.
64pub fn classify(target: &str) -> LinkKind {
65    let t = target.trim();
66    if t.chars().any(char::is_control) {
67        return LinkKind::Refused("The link contains control characters.".into());
68    }
69    if t.starts_with("\\\\") || t.starts_with("//") {
70        return LinkKind::Refused("Links to network locations aren't opened.".into());
71    }
72    if RangeRef::parse(t).is_some() {
73        return LinkKind::Internal(t.to_string());
74    }
75    let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) };
76    let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
77        && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
78    if !scheme_like {
79        return LinkKind::Internal(t.to_string());
80    }
81    let lower = scheme.to_ascii_lowercase();
82    if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() {
83        LinkKind::External(t.to_string())
84    } else {
85        LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed."))
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn only_http_https_and_mailto_leave_the_workbook() {
95        for ok in ["http://a.example/x", "HTTPS://A.example", "  https://a.example  ", "MailTo:a@b.example"] {
96            assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}");
97        }
98        assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into()));
99        for bad in [
100            "javascript:alert(1)",
101            "JaVaScRiPt:alert(1)",
102            "file:///etc/passwd",
103            "smb://host/share",
104            "ms-excel:ofe|u|http://x",
105            "\\\\host\\share\\x.exe",
106            "//host/share",
107            "C:\\Windows\\calc.exe",
108            "\tjavascript:1",
109            "java\nscript:1",
110            "\u{1}http://a.example",
111            "http:",
112            "mailto:",
113            "x-custom://a",
114        ] {
115            assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));
116        }
117    }
118
119    #[test]
120    fn references_stay_internal() {
121        for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", "   "] {
122            assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r));
123        }
124    }
125}