Skip to main content

rahti_native/
capabilities.rs

1//! What the WebView is allowed to ask the operating system for.
2//!
3//! ## Why this list is short
4//!
5//! In a browser, an XSS is a stolen session. In a native shell, an XSS is a
6//! stolen session *and* whatever the page can reach through the native bridge
7//! — so the size of that bridge is the size of the damage. A shell exposing
8//! `execute_process` or an unrestricted filesystem API has turned every markup
9//! bug in the application into remote code execution on the user's machine.
10//!
11//! So the bridge is an allowlist of narrow, named operations, and it starts as
12//! small as it can while still being useful. `choose_file` opens the platform
13//! picker and returns what the *user* selected; there is no command that reads
14//! a path the page names. That difference is the entire security model: the
15//! human, not the JavaScript, decides which file the application sees.
16//!
17//! ## Adding one
18//!
19//! A command has to be in [`commands`] before the generated shell will route
20//! it, and the shell's Tauri capability file has to list it too. Both, on
21//! purpose: the Rust allowlist is what this crate can test, and the capability
22//! file is what Tauri enforces. Neither alone.
23//!
24//! Every command must be safe to hand to a page that has been compromised.
25//! "The application only calls it with good values" is not an argument — the
26//! attacker calls it with the others.
27
28/// What a command lets the page do.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Capability {
31    /// Read-only facts about the host. No side effects.
32    Inspect,
33    /// Hands something to another program, or to the user.
34    Handoff,
35}
36
37/// One entry in the bridge allowlist.
38#[derive(Debug, Clone, Copy)]
39pub struct NativeCommand {
40    /// What `pp.native.invoke` names.
41    pub name: &'static str,
42    /// The capability string `pp.native.has(...)` answers for.
43    pub capability: &'static str,
44    pub kind: Capability,
45    /// One line, used by the generated shell's documentation.
46    pub summary: &'static str,
47}
48
49/// The whole allowlist.
50///
51/// Deliberately five. Each is either a fact about the host or an action the
52/// user completes themselves.
53pub fn commands() -> &'static [NativeCommand] {
54    &[
55        NativeCommand {
56            name: "platform",
57            capability: "platform",
58            kind: Capability::Inspect,
59            summary: "Which operating system the package is running on.",
60        },
61        NativeCommand {
62            name: "app_version",
63            capability: "app-version",
64            kind: Capability::Inspect,
65            summary: "The installed application's version.",
66        },
67        NativeCommand {
68            name: "app_data_dir",
69            capability: "app-data-dir",
70            kind: Capability::Inspect,
71            summary: "Where this installation keeps its data, for showing the user.",
72        },
73        NativeCommand {
74            name: "open_external",
75            capability: "open-external",
76            kind: Capability::Handoff,
77            summary: "Open an http, https or mailto URL in the user's own browser or mail client.",
78        },
79        NativeCommand {
80            name: "choose_file",
81            capability: "choose-file",
82            kind: Capability::Handoff,
83            summary: "Open the platform file picker and return what the user chose.",
84        },
85    ]
86}
87
88/// Whether the shell should route `name` at all.
89///
90/// The check the generated shell makes before dispatching, so a command that
91/// was removed from the allowlist stops working even if a route for it was
92/// left behind.
93pub fn is_allowed(name: &str) -> bool {
94    commands().iter().any(|command| command.name == name)
95}
96
97/// The capability strings, for the bridge script's `pp.native.has(...)`.
98pub fn capability_names() -> Vec<&'static str> {
99    commands().iter().map(|c| c.capability).collect()
100}
101
102/// Whether a URL may be handed to the operating system.
103///
104/// The check that keeps `open_external` from being a way to run things. A
105/// `file:` URL opens whatever the shell associates with the extension, and on
106/// Windows that includes executables and script hosts; `javascript:` and
107/// `data:` are ways back into a privileged context. Only the three schemes
108/// that mean "somewhere else, in another program" are allowed.
109///
110/// Scheme comparison is ASCII-case-insensitive because URL schemes are, and
111/// `JavaScript:` is the form that gets past a check that forgot.
112pub fn is_external_url(url: &str) -> bool {
113    let url = url.trim();
114
115    // A control character can split a URL across a line in whatever parses it
116    // next, which is how a check on the first line stops describing the whole
117    // value.
118    if url.is_empty() || url.chars().any(|c| c.is_control()) {
119        return false;
120    }
121
122    let Some((scheme, rest)) = url.split_once(':') else {
123        return false;
124    };
125    if rest.is_empty() {
126        return false;
127    }
128
129    match scheme.to_ascii_lowercase().as_str() {
130        // A host is required, so `http:///etc` and `https:/x` are not URLs
131        // that go anywhere.
132        "http" | "https" => rest.starts_with("//") && rest.len() > 2 && !rest.starts_with("///"),
133        "mailto" => true,
134        _ => false,
135    }
136}