Skip to main content

rightkit_tauri_shell/
nav.rs

1//! Navigation and window-open policy: what a webview may load in place, what leaves for the
2//! system browser, and what is refused.
3//!
4//! Merges ViewRight's `external_navigation` broker (an `https://open.<app>.invalid/?target=`
5//! hop that the frontend uses to ask the host to open a link) with CodeRight's `popup_policy`
6//! (web schemes leave the shell; only a size-shaped OAuth popup may become a child window).
7//! The decision functions are pure so every platform shares one policy.
8
9use serde::{Deserialize, Serialize};
10use tauri::Url;
11
12/// Schemes that may leave the shell for the system handler without a second consent step.
13pub const EXTERNAL_SCHEMES: &[&str] = &["http", "https", "mailto"];
14
15/// Schemes a webview loads in place (the app's own content and in-page documents).
16const INTERNAL_SCHEMES: &[&str] = &["tauri", "asset", "ipc", "about", "blob", "data", "http-ipc"];
17
18/// OAuth authorization origins allowed to open as an in-app popup (CodeRight list, plus the Atlassian
19/// and Salesforce sign-in origins). An app adds its own through [`NavPolicy::popup_origins`] or
20/// [`popup_origins_from_json`].
21pub const OAUTH_POPUP_ORIGINS: &[&str] = &[
22    "https://github.com",
23    "https://accounts.google.com",
24    "https://slack.com",
25    "https://login.microsoftonline.com",
26    "https://appleid.apple.com",
27    "https://mcp.atlassian.com",
28    "https://auth.atlassian.com",
29    "https://login.salesforce.com",
30    "https://test.salesforce.com",
31];
32
33/// Headers removed from an OAuth popup's response: a cross-origin opener policy severs `window.opener`,
34/// which breaks the OAuth `postMessage` hand-back.
35const COOP_HEADERS: &[&str] = &[
36    "cross-origin-opener-policy",
37    "cross-origin-opener-policy-report-only",
38];
39
40/// Remove the cross-origin opener policy headers (case-insensitively) from a response's header map.
41/// `None` in, `None` out. For a webview response hook on an OAuth popup.
42pub fn strip_coop_headers(
43    headers: Option<&std::collections::BTreeMap<String, Vec<String>>>,
44) -> Option<std::collections::BTreeMap<String, Vec<String>>> {
45    headers.map(|h| {
46        h.iter()
47            .filter(|(name, _)| !COOP_HEADERS.contains(&name.to_ascii_lowercase().as_str()))
48            .map(|(name, values)| (name.clone(), values.clone()))
49            .collect()
50    })
51}
52
53/// Parse an app-settings `popup_allowed_origins` value (a JSON array of origin strings) into popup
54/// origins. Invalid entries are ignored: anything that is not a bare `https://host[:port]` origin in
55/// canonical form, or a non-array value, yields no entry. Duplicates are dropped.
56pub fn popup_origins_from_json(raw: &str) -> Vec<String> {
57    let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(raw) else {
58        return Vec::new();
59    };
60    let mut out: Vec<String> = Vec::new();
61    for item in items {
62        let Some(text) = item.as_str() else { continue };
63        let Ok(url) = Url::parse(text) else { continue };
64        if url.scheme() != "https" || origin_of(&url).as_deref() != Some(text) {
65            continue;
66        }
67        if !out.iter().any(|o| o == text) {
68            out.push(text.to_string());
69        }
70    }
71    out
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(tag = "kind", rename_all = "kebab-case")]
76pub enum NavDecision {
77    /// Load in the webview.
78    Allow,
79    /// Hand to the system handler (browser, mail client); the webview stays put.
80    External { url: String },
81    /// Refuse (unknown or dangerous scheme).
82    Block,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "kebab-case")]
87pub enum WindowOpenDecision {
88    External {
89        url: String,
90    },
91    /// An allow-listed popup (OAuth) that may become an in-app child window.
92    Popup,
93    Deny,
94}
95
96#[derive(Debug, Clone)]
97pub struct NavPolicy {
98    /// Extra hosts served in place, e.g. an app's own `https://app.example` surface.
99    pub internal_hosts: Vec<String>,
100    /// Allow `localhost` / loopback (Vite dev server). Defaults on only with the `dev-overrides` feature.
101    pub allow_loopback: bool,
102    /// ViewRight-style broker host. `https://<host>/?target=<url>` navigates nothing and asks the
103    /// shell to open `target` externally (only when `target` has an external scheme).
104    pub broker_host: Option<String>,
105    /// Origins (scheme://host[:port]) allowed to open popup windows in-app.
106    pub popup_origins: Vec<String>,
107    /// Extra schemes the host serves as its own content, loaded in place like `tauri:` (a custom
108    /// protocol registered by the app). Empty by default; add with [`NavPolicy::with_internal_scheme`].
109    pub internal_schemes: Vec<String>,
110}
111
112/// Schemes no host may mark internal: web and script schemes would let the webview load the world,
113/// and `file:` the disk, in place.
114const NEVER_INTERNAL: &[&str] = &[
115    "http",
116    "https",
117    "mailto",
118    "file",
119    "javascript",
120    "vbscript",
121    "ftp",
122    "ws",
123    "wss",
124];
125
126/// `Ok(lowercased scheme)` when `scheme` can be treated as internal content.
127pub fn validate_internal_scheme(scheme: &str) -> Result<String, String> {
128    let lower = scheme.to_ascii_lowercase();
129    let mut chars = lower.chars();
130    let well_formed = chars.next().is_some_and(|c| c.is_ascii_lowercase())
131        && chars
132            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '-' | '.'));
133    if !well_formed {
134        return Err(format!("{scheme:?} is not a URL scheme"));
135    }
136    if NEVER_INTERNAL.contains(&lower.as_str()) {
137        return Err(format!("{lower}: cannot be an internal scheme"));
138    }
139    Ok(lower)
140}
141
142impl Default for NavPolicy {
143    fn default() -> Self {
144        Self {
145            internal_hosts: Vec::new(),
146            allow_loopback: cfg!(feature = "dev-overrides"),
147            broker_host: None,
148            popup_origins: OAUTH_POPUP_ORIGINS.iter().map(|s| s.to_string()).collect(),
149            internal_schemes: Vec::new(),
150        }
151    }
152}
153
154pub fn external_scheme_allowed(target: &str) -> bool {
155    let lower = target.trim_start().to_ascii_lowercase();
156    EXTERNAL_SCHEMES
157        .iter()
158        .any(|s| lower.starts_with(&format!("{s}:")))
159        && !target.chars().any(char::is_control)
160}
161
162fn is_loopback(host: &str) -> bool {
163    matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
164}
165
166/// Origin serialisation matching JavaScript's `URL#origin` for http(s).
167pub fn origin_of(url: &Url) -> Option<String> {
168    let host = url.host_str()?;
169    let host = if host.contains(':') {
170        format!("[{host}]")
171    } else {
172        host.to_string()
173    };
174    let default = match url.scheme() {
175        "http" => Some(80),
176        "https" => Some(443),
177        _ => None,
178    };
179    let port = url.port().filter(|p| Some(*p) != default);
180    Some(match port {
181        Some(p) => format!("{}://{host}:{p}", url.scheme()),
182        None => format!("{}://{host}", url.scheme()),
183    })
184}
185
186impl NavPolicy {
187    /// This policy with `scheme` also loaded in place (see [`NavPolicy::internal_schemes`]). Errors
188    /// on a malformed scheme or a web, script or file scheme. The built-in set is unchanged.
189    pub fn with_internal_scheme(mut self, scheme: &str) -> Result<Self, String> {
190        let scheme = validate_internal_scheme(scheme)?;
191        if !self.internal_schemes.contains(&scheme) {
192            self.internal_schemes.push(scheme);
193        }
194        Ok(self)
195    }
196
197    fn serves_in_place(&self, url: &Url) -> bool {
198        let scheme = url.scheme();
199        if INTERNAL_SCHEMES.contains(&scheme) || self.internal_schemes.iter().any(|s| s == scheme) {
200            return true;
201        }
202        let Some(host) = url.host_str() else {
203            return false;
204        };
205        // Tauri serves the bundle from `tauri.localhost` on Windows/Android.
206        if matches!(scheme, "http" | "https") && host == "tauri.localhost" {
207            return true;
208        }
209        if self.internal_hosts.iter().any(|h| h == host) && matches!(scheme, "http" | "https") {
210            return true;
211        }
212        self.allow_loopback && matches!(scheme, "http" | "https") && is_loopback(host)
213    }
214
215    /// Decide a top-level (or `on_navigation`) load.
216    pub fn decide(&self, url: &Url) -> NavDecision {
217        if let Some(host) = &self.broker_host {
218            if url.scheme() == "https" && url.host_str() == Some(host.as_str()) {
219                let target = url
220                    .query_pairs()
221                    .find_map(|(k, v)| (k == "target").then(|| v.into_owned()));
222                return match target {
223                    Some(t) if external_scheme_allowed(&t) => NavDecision::External { url: t },
224                    _ => NavDecision::Block,
225                };
226            }
227        }
228        if self.serves_in_place(url) {
229            return NavDecision::Allow;
230        }
231        if EXTERNAL_SCHEMES.contains(&url.scheme()) {
232            return NavDecision::External {
233                url: url.to_string(),
234            };
235        }
236        NavDecision::Block
237    }
238
239    /// Decide a page-initiated `window.open` / `target=_blank`.
240    /// `popup_shaped` is true for a sized popup request; only those from a trusted opener to an
241    /// allow-listed HTTPS origin become windows, everything else leaves for the browser. A non-web
242    /// scheme is `Deny`; ask [`NavPolicy::protocol_consent_scheme`] whether it needs person consent.
243    pub fn decide_window_open(
244        &self,
245        url: &Url,
246        popup_shaped: bool,
247        opener: Option<&Url>,
248    ) -> WindowOpenDecision {
249        self.decide_window_open_pinned(url, popup_shaped, opener, None)
250    }
251
252    /// [`NavPolicy::decide_window_open`] with the app's pinned server origin (`scheme://host[:port]`).
253    /// When `pinned_origin` is `Some`, a popup opens only when the opener's origin equals it (an unknown
254    /// opener is external); when `None`, an unknown opener defers to the origin allow-list and a known
255    /// one must be served in place.
256    pub fn decide_window_open_pinned(
257        &self,
258        url: &Url,
259        popup_shaped: bool,
260        opener: Option<&Url>,
261        pinned_origin: Option<&str>,
262    ) -> WindowOpenDecision {
263        if !EXTERNAL_SCHEMES.contains(&url.scheme()) {
264            return WindowOpenDecision::Deny;
265        }
266        if popup_shaped && url.scheme() == "https" {
267            let target = origin_of(url);
268            // Wry reports only the target.
269            let from_trusted = match pinned_origin {
270                Some(pin) => opener.and_then(origin_of).as_deref() == Some(pin),
271                None => opener.is_none_or(|o| self.serves_in_place(o)),
272            };
273            if from_trusted && target.is_some_and(|t| self.popup_origins.contains(&t)) {
274                return WindowOpenDecision::Popup;
275            }
276        }
277        WindowOpenDecision::External {
278            url: url.to_string(),
279        }
280    }
281
282    /// The scheme of a non-web `window.open` target (for example `zoom:` or `ms-settings:`) that an app
283    /// with a consent surface may confirm with the person before handing it to the system. `None` for
284    /// web schemes and for schemes the shell serves or refuses outright (internal, `file:`, `javascript:`).
285    /// [`NavPolicy::decide_window_open`] returns `Deny` for every such target either way.
286    pub fn protocol_consent_scheme(&self, url: &Url) -> Option<String> {
287        let scheme = url.scheme();
288        if EXTERNAL_SCHEMES.contains(&scheme)
289            || INTERNAL_SCHEMES.contains(&scheme)
290            || NEVER_INTERNAL.contains(&scheme)
291            || self.internal_schemes.iter().any(|s| s == scheme)
292        {
293            return None;
294        }
295        Some(scheme.to_string())
296    }
297}