use serde::{Deserialize, Serialize};
use tauri::Url;
pub const EXTERNAL_SCHEMES: &[&str] = &["http", "https", "mailto"];
const INTERNAL_SCHEMES: &[&str] = &["tauri", "asset", "ipc", "about", "blob", "data", "http-ipc"];
pub const OAUTH_POPUP_ORIGINS: &[&str] = &[
"https://github.com",
"https://accounts.google.com",
"https://slack.com",
"https://login.microsoftonline.com",
"https://appleid.apple.com",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum NavDecision {
Allow,
External { url: String },
Block,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WindowOpenDecision {
External {
url: String,
},
Popup,
Deny,
}
#[derive(Debug, Clone)]
pub struct NavPolicy {
pub internal_hosts: Vec<String>,
pub allow_loopback: bool,
pub broker_host: Option<String>,
pub popup_origins: Vec<String>,
pub internal_schemes: Vec<String>,
}
const NEVER_INTERNAL: &[&str] = &[
"http",
"https",
"mailto",
"file",
"javascript",
"vbscript",
"ftp",
"ws",
"wss",
];
pub fn validate_internal_scheme(scheme: &str) -> Result<String, String> {
let lower = scheme.to_ascii_lowercase();
let mut chars = lower.chars();
let well_formed = chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '-' | '.'));
if !well_formed {
return Err(format!("{scheme:?} is not a URL scheme"));
}
if NEVER_INTERNAL.contains(&lower.as_str()) {
return Err(format!("{lower}: cannot be an internal scheme"));
}
Ok(lower)
}
impl Default for NavPolicy {
fn default() -> Self {
Self {
internal_hosts: Vec::new(),
allow_loopback: cfg!(debug_assertions),
broker_host: None,
popup_origins: OAUTH_POPUP_ORIGINS.iter().map(|s| s.to_string()).collect(),
internal_schemes: Vec::new(),
}
}
}
pub fn external_scheme_allowed(target: &str) -> bool {
let lower = target.trim_start().to_ascii_lowercase();
EXTERNAL_SCHEMES
.iter()
.any(|s| lower.starts_with(&format!("{s}:")))
&& !target.chars().any(char::is_control)
}
fn is_loopback(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
}
pub fn origin_of(url: &Url) -> Option<String> {
let host = url.host_str()?;
let host = if host.contains(':') {
format!("[{host}]")
} else {
host.to_string()
};
let default = match url.scheme() {
"http" => Some(80),
"https" => Some(443),
_ => None,
};
let port = url.port().filter(|p| Some(*p) != default);
Some(match port {
Some(p) => format!("{}://{host}:{p}", url.scheme()),
None => format!("{}://{host}", url.scheme()),
})
}
impl NavPolicy {
pub fn with_internal_scheme(mut self, scheme: &str) -> Result<Self, String> {
let scheme = validate_internal_scheme(scheme)?;
if !self.internal_schemes.contains(&scheme) {
self.internal_schemes.push(scheme);
}
Ok(self)
}
fn serves_in_place(&self, url: &Url) -> bool {
let scheme = url.scheme();
if INTERNAL_SCHEMES.contains(&scheme) || self.internal_schemes.iter().any(|s| s == scheme) {
return true;
}
let Some(host) = url.host_str() else {
return false;
};
if matches!(scheme, "http" | "https") && host == "tauri.localhost" {
return true;
}
if self.internal_hosts.iter().any(|h| h == host) && matches!(scheme, "http" | "https") {
return true;
}
self.allow_loopback && matches!(scheme, "http" | "https") && is_loopback(host)
}
pub fn decide(&self, url: &Url) -> NavDecision {
if let Some(host) = &self.broker_host {
if url.scheme() == "https" && url.host_str() == Some(host.as_str()) {
let target = url
.query_pairs()
.find_map(|(k, v)| (k == "target").then(|| v.into_owned()));
return match target {
Some(t) if external_scheme_allowed(&t) => NavDecision::External { url: t },
_ => NavDecision::Block,
};
}
}
if self.serves_in_place(url) {
return NavDecision::Allow;
}
if EXTERNAL_SCHEMES.contains(&url.scheme()) {
return NavDecision::External {
url: url.to_string(),
};
}
NavDecision::Block
}
pub fn decide_window_open(
&self,
url: &Url,
popup_shaped: bool,
opener: Option<&Url>,
) -> WindowOpenDecision {
if !EXTERNAL_SCHEMES.contains(&url.scheme()) {
return WindowOpenDecision::Deny;
}
if popup_shaped && url.scheme() == "https" {
let target = origin_of(url);
let from_trusted = opener.is_none_or(|o| self.serves_in_place(o));
if from_trusted && target.is_some_and(|t| self.popup_origins.contains(&t)) {
return WindowOpenDecision::Popup;
}
}
WindowOpenDecision::External {
url: url.to_string(),
}
}
}