1use serde::{Deserialize, Serialize};
10use tauri::Url;
11
12pub const EXTERNAL_SCHEMES: &[&str] = &["http", "https", "mailto"];
14
15const INTERNAL_SCHEMES: &[&str] = &["tauri", "asset", "ipc", "about", "blob", "data", "http-ipc"];
17
18pub 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
33const COOP_HEADERS: &[&str] = &[
36 "cross-origin-opener-policy",
37 "cross-origin-opener-policy-report-only",
38];
39
40pub 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
53pub 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 Allow,
79 External { url: String },
81 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 Popup,
93 Deny,
94}
95
96#[derive(Debug, Clone)]
97pub struct NavPolicy {
98 pub internal_hosts: Vec<String>,
100 pub allow_loopback: bool,
102 pub broker_host: Option<String>,
105 pub popup_origins: Vec<String>,
107 pub internal_schemes: Vec<String>,
110}
111
112const NEVER_INTERNAL: &[&str] = &[
115 "http",
116 "https",
117 "mailto",
118 "file",
119 "javascript",
120 "vbscript",
121 "ftp",
122 "ws",
123 "wss",
124];
125
126pub 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
166pub 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 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 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 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 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 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 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 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}