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",
"https://mcp.atlassian.com",
"https://auth.atlassian.com",
"https://login.salesforce.com",
"https://test.salesforce.com",
];
const COOP_HEADERS: &[&str] = &[
"cross-origin-opener-policy",
"cross-origin-opener-policy-report-only",
];
pub fn strip_coop_headers(
headers: Option<&std::collections::BTreeMap<String, Vec<String>>>,
) -> Option<std::collections::BTreeMap<String, Vec<String>>> {
headers.map(|h| {
h.iter()
.filter(|(name, _)| !COOP_HEADERS.contains(&name.to_ascii_lowercase().as_str()))
.map(|(name, values)| (name.clone(), values.clone()))
.collect()
})
}
pub fn popup_origins_from_json(raw: &str) -> Vec<String> {
let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(raw) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for item in items {
let Some(text) = item.as_str() else { continue };
let Ok(url) = Url::parse(text) else { continue };
if url.scheme() != "https" || origin_of(&url).as_deref() != Some(text) {
continue;
}
if !out.iter().any(|o| o == text) {
out.push(text.to_string());
}
}
out
}
#[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!(feature = "dev-overrides"),
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 {
self.decide_window_open_pinned(url, popup_shaped, opener, None)
}
pub fn decide_window_open_pinned(
&self,
url: &Url,
popup_shaped: bool,
opener: Option<&Url>,
pinned_origin: Option<&str>,
) -> WindowOpenDecision {
if !EXTERNAL_SCHEMES.contains(&url.scheme()) {
return WindowOpenDecision::Deny;
}
if popup_shaped && url.scheme() == "https" {
let target = origin_of(url);
let from_trusted = match pinned_origin {
Some(pin) => opener.and_then(origin_of).as_deref() == Some(pin),
None => 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(),
}
}
pub fn protocol_consent_scheme(&self, url: &Url) -> Option<String> {
let scheme = url.scheme();
if EXTERNAL_SCHEMES.contains(&scheme)
|| INTERNAL_SCHEMES.contains(&scheme)
|| NEVER_INTERNAL.contains(&scheme)
|| self.internal_schemes.iter().any(|s| s == scheme)
{
return None;
}
Some(scheme.to_string())
}
}