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}