use crate::errors::ExternalOpenError;
use crate::url_path::url_scheme;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationDecision {
Proceed,
OpenExternally,
Block,
}
pub const IPC_OPEN_EXTERNAL_PREFIX: &str = "mbr:open-external:";
const BLOCKED_SCHEMES: [&str; 3] = ["javascript", "vbscript", "data"];
const IN_WINDOW_SCHEMES: [&str; 2] = ["about", "blob"];
const WEB_SCHEMES: [&str; 2] = ["http", "https"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SchemeClass {
Absent,
Blocked,
InWindow,
Web,
Application,
}
fn classify_scheme(url: &str) -> SchemeClass {
let Some(scheme) = url_scheme(url) else {
return SchemeClass::Absent;
};
if matches_ignore_case(&BLOCKED_SCHEMES, scheme) {
SchemeClass::Blocked
} else if matches_ignore_case(&IN_WINDOW_SCHEMES, scheme) {
SchemeClass::InWindow
} else if matches_ignore_case(&WEB_SCHEMES, scheme) {
SchemeClass::Web
} else {
SchemeClass::Application
}
}
pub fn decide_without_frame_info(url: &str) -> NavigationDecision {
match classify_scheme(url) {
SchemeClass::Blocked => NavigationDecision::Block,
SchemeClass::Absent | SchemeClass::InWindow | SchemeClass::Web => {
NavigationDecision::Proceed
}
SchemeClass::Application => NavigationDecision::OpenExternally,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SiteOrigin {
origin: Option<String>,
}
impl SiteOrigin {
pub fn new(site_url: &str) -> Self {
Self {
origin: origin_of(site_url),
}
}
pub fn decide(&self, url: &str) -> NavigationDecision {
match classify_scheme(url) {
SchemeClass::Blocked => NavigationDecision::Block,
SchemeClass::Absent | SchemeClass::InWindow => NavigationDecision::Proceed,
SchemeClass::Web | SchemeClass::Application => match &self.origin {
None => NavigationDecision::Proceed,
Some(origin) if covers(origin, url) => NavigationDecision::Proceed,
Some(_) => NavigationDecision::OpenExternally,
},
}
}
}
pub fn parse_ipc_open_request<'a>(origin: &SiteOrigin, payload: &'a str) -> Option<&'a str> {
let url = payload.strip_prefix(IPC_OPEN_EXTERNAL_PREFIX)?;
let is_web = matches!(classify_scheme(url), SchemeClass::Web);
(is_web && origin.decide(url) == NavigationDecision::OpenExternally).then_some(url)
}
pub fn apply_decision<F>(decision: NavigationDecision, url: &str, open_externally: F) -> bool
where
F: FnOnce(&str),
{
match decision {
NavigationDecision::Proceed => true,
NavigationDecision::OpenExternally => {
open_externally(url);
false
}
NavigationDecision::Block => {
tracing::debug!("Refusing navigation to {url}: scheme is not safe to hand off");
false
}
}
}
static GUI_ACTIVE: AtomicBool = AtomicBool::new(false);
pub(crate) fn mark_gui_active() {
GUI_ACTIVE.store(true, Ordering::Relaxed);
}
pub(crate) fn open_external(url: &str) -> Result<(), ExternalOpenError> {
open_external_guarded(GUI_ACTIVE.load(Ordering::Relaxed), url, open_external_impl)
}
fn open_external_guarded<F>(gui_active: bool, url: &str, launch: F) -> Result<(), ExternalOpenError>
where
F: FnOnce(&str) -> Result<(), ExternalOpenError>,
{
if !gui_active {
tracing::warn!(
"Refusing to hand {url} to the operating system: no GUI window is running in this process"
);
return Err(ExternalOpenError::GuiOnly {
url: url.to_string(),
});
}
launch(url)
}
fn origin_of(site_url: &str) -> Option<String> {
let authority_start = site_url.find("://")? + "://".len();
let authority_end = site_url[authority_start..]
.find(['/', '?', '#'])
.map_or(site_url.len(), |offset| authority_start + offset);
(authority_end > authority_start).then(|| site_url[..authority_end].to_ascii_lowercase())
}
fn covers(origin: &str, url: &str) -> bool {
url.get(..origin.len())
.is_some_and(|head| head.eq_ignore_ascii_case(origin))
&& matches!(
url.as_bytes().get(origin.len()).copied(),
None | Some(b'/' | b'?' | b'#')
)
}
fn matches_ignore_case(known: &[&str], scheme: &str) -> bool {
known.iter().any(|known| scheme.eq_ignore_ascii_case(known))
}
#[cfg(target_os = "macos")]
fn open_external_impl(url: &str) -> Result<(), ExternalOpenError> {
use objc2_app_kit::NSWorkspace;
use objc2_foundation::{NSString, NSURL};
let parsed = NSURL::URLWithString(&NSString::from_str(url)).ok_or_else(|| {
ExternalOpenError::Malformed {
url: url.to_string(),
}
})?;
NSWorkspace::sharedWorkspace()
.openURL(&parsed)
.then_some(())
.ok_or_else(|| ExternalOpenError::LaunchFailed {
url: url.to_string(),
reason: "no application is registered for this scheme".to_string(),
})
}
#[cfg(target_os = "windows")]
fn open_external_impl(url: &str) -> Result<(), ExternalOpenError> {
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
let wide: Vec<u16> = url.encode_utf16().chain(std::iter::once(0)).collect();
let status = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
std::ptr::null(),
wide.as_ptr(),
std::ptr::null(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
if status as isize > 32 {
Ok(())
} else {
Err(ExternalOpenError::LaunchFailed {
url: url.to_string(),
reason: format!("ShellExecuteW failed with code {}", status as isize),
})
}
}
#[cfg(target_os = "linux")]
fn open_external_impl(url: &str) -> Result<(), ExternalOpenError> {
gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>).map_err(|e| {
ExternalOpenError::LaunchFailed {
url: url.to_string(),
reason: e.to_string(),
}
})
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn open_external_impl(url: &str) -> Result<(), ExternalOpenError> {
Err(ExternalOpenError::LaunchFailed {
url: url.to_string(),
reason: "mbr has no system URL handler for this platform".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
const SITE: &str = "http://127.0.0.1:5220/";
fn site() -> SiteOrigin {
SiteOrigin::new(SITE)
}
const MESSAGE_URL: &str =
"message://%3CCAEn-OzgEreVRuNkdnb9gdFNLXByerYCLraYJGRvjSvXw9chVMQ@mail.gmail.com%3E";
fn run(decision: NavigationDecision, url: &str) -> (bool, Option<String>) {
let handed_off = RefCell::new(None);
let proceed = apply_decision(decision, url, |u| {
*handed_off.borrow_mut() = Some(u.to_string());
});
(proceed, handed_off.into_inner())
}
fn run_nav(url: &str) -> (bool, Option<String>) {
run(decide_without_frame_info(url), url)
}
fn run_popup(origin: &SiteOrigin, url: &str) -> (bool, Option<String>) {
run(origin.decide(url), url)
}
#[test]
fn test_cross_origin_https_proceeds_so_iframe_embeds_are_never_cancelled() {
let embed = "https://www.youtube-nocookie.com/embed/abc123";
assert_eq!(
decide_without_frame_info(embed),
NavigationDecision::Proceed,
"cancelling this would blank YouTube embeds in GUI mode"
);
let (proceed, handed_off) = run_nav(embed);
assert!(proceed, "the embed must be allowed to load");
assert_eq!(
handed_off, None,
"an embed must never open the system browser"
);
}
#[test]
fn test_decide_without_frame_info_table() {
let cases: &[(&str, NavigationDecision)] = &[
("http://127.0.0.1:5220/docs/", NavigationDecision::Proceed),
("https://example.com/", NavigationDecision::Proceed),
("http://example.com/path", NavigationDecision::Proceed),
(
"https://www.youtube-nocookie.com/embed/abc123",
NavigationDecision::Proceed,
),
("HTTPS://Example.COM/", NavigationDecision::Proceed),
(MESSAGE_URL, NavigationDecision::OpenExternally),
(
"mailto:someone@example.com",
NavigationDecision::OpenExternally,
),
("tel:+15555550123", NavigationDecision::OpenExternally),
(
"zoommtg://zoom.us/join?confno=1234567890",
NavigationDecision::OpenExternally,
),
(
"x-devonthink-item://8A3B0C1D-2E4F",
NavigationDecision::OpenExternally,
),
(
"slack://channel?team=T1",
NavigationDecision::OpenExternally,
),
("file:///etc/hosts", NavigationDecision::OpenExternally),
("javascript:void(0)", NavigationDecision::Block),
("JavaScript:alert(1)", NavigationDecision::Block),
("vbscript:msgbox(1)", NavigationDecision::Block),
(
"data:text/html;base64,PHNjcmlwdD4=",
NavigationDecision::Block,
),
("about:blank", NavigationDecision::Proceed),
(
"blob:http://127.0.0.1:5220/550e8400",
NavigationDecision::Proceed,
),
("", NavigationDecision::Proceed),
("/docs/guide/", NavigationDecision::Proceed),
("#section", NavigationDecision::Proceed),
];
for (url, expected) in cases {
assert_eq!(
decide_without_frame_info(url),
*expected,
"decide_without_frame_info({url:?}) should be {expected:?}"
);
}
}
#[test]
fn test_decide_table() {
let site = site();
let cases: &[(&str, NavigationDecision)] = &[
("http://127.0.0.1:5220/", NavigationDecision::Proceed),
("http://127.0.0.1:5220", NavigationDecision::Proceed),
(
"http://127.0.0.1:5220/docs/guide/",
NavigationDecision::Proceed,
),
(
"http://127.0.0.1:5220/docs/#section",
NavigationDecision::Proceed,
),
("http://127.0.0.1:5220/#top", NavigationDecision::Proceed),
("http://127.0.0.1:5220/?q=1", NavigationDecision::Proceed),
(
"http://127.0.0.1:5220/.mbr/site.json?v=2#x",
NavigationDecision::Proceed,
),
("HTTP://127.0.0.1:5220/docs/", NavigationDecision::Proceed),
("https://example.com/", NavigationDecision::OpenExternally),
(
"http://example.com/path",
NavigationDecision::OpenExternally,
),
(
"https://127.0.0.1:5220/",
NavigationDecision::OpenExternally,
),
("http://127.0.0.1:5221/", NavigationDecision::OpenExternally),
("http://localhost:5220/", NavigationDecision::OpenExternally),
(
"http://127.0.0.1:52200/",
NavigationDecision::OpenExternally,
),
(
"http://127.0.0.1:5220.evil.example/",
NavigationDecision::OpenExternally,
),
(
"http://127.0.0.1:5220@evil.example/",
NavigationDecision::OpenExternally,
),
(MESSAGE_URL, NavigationDecision::OpenExternally),
(
"mailto:someone@example.com",
NavigationDecision::OpenExternally,
),
(
"mailto:someone@example.com?subject=Hi%20there",
NavigationDecision::OpenExternally,
),
("tel:+15555550123", NavigationDecision::OpenExternally),
(
"zoommtg://zoom.us/join?confno=1234567890",
NavigationDecision::OpenExternally,
),
(
"x-devonthink-item://8A3B0C1D-2E4F",
NavigationDecision::OpenExternally,
),
(
"slack://channel?team=T1&id=C1",
NavigationDecision::OpenExternally,
),
("file:///etc/hosts", NavigationDecision::OpenExternally),
("javascript:void(0)", NavigationDecision::Block),
("JavaScript:alert(1)", NavigationDecision::Block),
("vbscript:msgbox(1)", NavigationDecision::Block),
(
"data:text/html;base64,PHNjcmlwdD4=",
NavigationDecision::Block,
),
("DATA:text/html,<b>x</b>", NavigationDecision::Block),
("about:blank", NavigationDecision::Proceed),
("about:srcdoc", NavigationDecision::Proceed),
(
"blob:http://127.0.0.1:5220/550e8400-e29b",
NavigationDecision::Proceed,
),
("", NavigationDecision::Proceed),
("/docs/guide/", NavigationDecision::Proceed),
("#section", NavigationDecision::Proceed),
];
for (url, expected) in cases {
assert_eq!(
site.decide(url),
*expected,
"decide({url:?}) should be {expected:?}"
);
}
}
#[test]
fn test_percent_encoding_reaches_the_os_verbatim() {
let (proceed, handed_off) = run_nav(MESSAGE_URL);
assert!(!proceed, "an external URL must not also load in-window");
let handed_off = handed_off.expect("message: URL should reach the OS");
assert_eq!(handed_off, MESSAGE_URL);
assert!(
handed_off.contains("%3C"),
"leading %3C must not be decoded"
);
assert!(
handed_off.contains("%3E"),
"trailing %3E must not be decoded"
);
assert!(!handed_off.contains("%253C"), "must not be re-encoded");
}
#[test]
fn test_external_urls_are_never_rewritten() {
let site = site();
let urls = [
"https://example.com/a%20b/c?d=%26e#f%2Fg",
"https://example.com/caf%C3%A9",
"https://example.com/café",
"mailto:a@b.example?subject=%5Bmbr%5D%20hi&body=one%0Atwo",
"zoommtg://zoom.us/join?confno=1&pwd=%2Fslash%2B",
];
for url in urls {
let (proceed, handed_off) = run_popup(&site, url);
assert!(!proceed, "{url} should not navigate in-window");
assert_eq!(handed_off.as_deref(), Some(url), "{url} was rewritten");
}
}
#[test]
fn test_blocked_schemes_reach_neither_the_window_nor_the_os() {
let site = site();
for url in [
"javascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html,x",
] {
for (proceed, handed_off) in [run_nav(url), run_popup(&site, url)] {
assert!(!proceed, "{url} must not navigate in-window");
assert_eq!(handed_off, None, "{url} must never reach the OS");
}
}
}
#[test]
fn test_same_origin_navigations_are_not_handed_off() {
let site = site();
for url in [
"http://127.0.0.1:5220/",
"http://127.0.0.1:5220/docs/#anchor",
"about:blank",
] {
for (proceed, handed_off) in [run_nav(url), run_popup(&site, url)] {
assert!(proceed, "{url} should navigate in-window");
assert_eq!(handed_off, None, "{url} must not reach the OS");
}
}
}
#[test]
fn test_ipc_accepts_an_off_origin_web_url_verbatim() {
let site = site();
assert_eq!(
parse_ipc_open_request(
&site,
"mbr:open-external:https://example.com/a%20b?c=%26d#e"
),
Some("https://example.com/a%20b?c=%26d#e"),
"the URL must survive the round trip unchanged"
);
assert_eq!(
parse_ipc_open_request(&site, "mbr:open-external:http://example.com/"),
Some("http://example.com/")
);
}
#[test]
fn test_ipc_rejects_everything_a_hostile_page_could_post() {
let site = site();
let rejected = [
"hello",
"",
"mbr:open-external",
"open-external:https://example.com/",
" mbr:open-external:https://example.com/",
"mbr:open-external:javascript:alert(1)",
"mbr:open-external:vbscript:msgbox(1)",
"mbr:open-external:data:text/html;base64,PHNjcmlwdD4=",
"mbr:open-external:http://127.0.0.1:5220/",
"mbr:open-external:http://127.0.0.1:5220/docs/guide/",
"mbr:open-external:mailto:someone@example.com",
"mbr:open-external:zoommtg://zoom.us/join?confno=1",
"mbr:open-external:file:///etc/passwd",
"mbr:open-external:about:blank",
"mbr:open-external:blob:http://127.0.0.1:5220/550e8400",
"mbr:open-external:/docs/guide/",
"mbr:open-external:#anchor",
"mbr:open-external:",
];
for payload in rejected {
assert_eq!(
parse_ipc_open_request(&site, payload),
None,
"IPC payload {payload:?} must be refused"
);
}
}
#[test]
fn test_ipc_is_not_fooled_by_origin_lookalikes() {
let site = site();
for url in [
"http://127.0.0.1:5220.evil.example/",
"http://127.0.0.1:5220@evil.example/",
"http://127.0.0.1:52200/",
] {
assert_eq!(
parse_ipc_open_request(&site, &format!("{IPC_OPEN_EXTERNAL_PREFIX}{url}")),
Some(url),
"{url} is a different origin and must be allowed out"
);
}
}
#[test]
fn test_ipc_refuses_everything_when_the_origin_is_unknown() {
let unknown = SiteOrigin::new("not a url");
assert_eq!(
parse_ipc_open_request(&unknown, "mbr:open-external:https://example.com/"),
None
);
}
fn recording_launcher(
seen: &RefCell<Vec<String>>,
) -> impl FnOnce(&str) -> Result<(), ExternalOpenError> + '_ {
move |url| {
seen.borrow_mut().push(url.to_string());
Ok(())
}
}
#[test]
fn open_external_refuses_when_gui_is_not_running() {
let reached_os = RefCell::new(Vec::new());
let result = open_external_guarded(false, MESSAGE_URL, recording_launcher(&reached_os));
assert!(
matches!(result, Err(ExternalOpenError::GuiOnly { ref url }) if url == MESSAGE_URL),
"a non-GUI process must refuse with GuiOnly, got {result:?}"
);
assert!(
reached_os.into_inner().is_empty(),
"the refusal must happen BEFORE the operating system is touched"
);
}
#[test]
fn open_external_refuses_every_url_when_gui_is_not_running() {
for url in [
MESSAGE_URL,
"mailto:someone@example.com",
"zoommtg://zoom.us/join?confno=1234567890",
"file:///etc/passwd",
"https://example.com/",
"x-devonthink-item://8A3B0C1D-2E4F",
"",
] {
let reached_os = RefCell::new(Vec::new());
let result = open_external_guarded(false, url, recording_launcher(&reached_os));
assert!(
matches!(result, Err(ExternalOpenError::GuiOnly { .. })),
"{url:?} must be refused outside GUI mode, got {result:?}"
);
assert!(
reached_os.into_inner().is_empty(),
"{url:?} must not reach the operating system outside GUI mode"
);
}
}
#[test]
fn open_external_reaches_the_launcher_verbatim_when_the_gui_is_running() {
let reached_os = RefCell::new(Vec::new());
let result = open_external_guarded(true, MESSAGE_URL, recording_launcher(&reached_os));
assert!(result.is_ok(), "a GUI process may launch, got {result:?}");
assert_eq!(
reached_os.into_inner(),
vec![MESSAGE_URL.to_string()],
"the launcher must see the URL exactly as the webview gave it"
);
}
#[test]
fn open_external_surfaces_launcher_failures_unchanged() {
let result = open_external_guarded(true, "zoommtg://zoom.us/join", |url| {
Err(ExternalOpenError::LaunchFailed {
url: url.to_string(),
reason: "no application is registered for this scheme".to_string(),
})
});
assert!(
matches!(result, Err(ExternalOpenError::LaunchFailed { .. })),
"expected the launcher's own error, got {result:?}"
);
}
#[test]
fn open_external_is_wired_to_the_gui_latch_and_defaults_to_refusing() {
assert!(
!GUI_ACTIVE.load(Ordering::Relaxed),
"no test may call mark_gui_active(); the latch must still be closed here"
);
assert!(
matches!(
open_external("https://example.com/"),
Err(ExternalOpenError::GuiOnly { .. })
),
"open_external must consult the latch, not just open_external_guarded"
);
}
#[test]
fn gui_only_refusal_explains_itself() {
let message = ExternalOpenError::GuiOnly {
url: "https://example.com/".to_string(),
}
.to_string();
assert!(message.contains("https://example.com/"), "{message}");
assert!(message.contains("GUI-only"), "{message}");
}
#[test]
fn test_origin_of() {
assert_eq!(
origin_of("http://127.0.0.1:5220/"),
Some("http://127.0.0.1:5220".to_string())
);
assert_eq!(
origin_of("http://0.0.0.0:8080/docs/guide/"),
Some("http://0.0.0.0:8080".to_string())
);
assert_eq!(
origin_of("HTTP://LocalHost:5220"),
Some("http://localhost:5220".to_string())
);
assert_eq!(origin_of("not a url"), None);
assert_eq!(origin_of("http:///no-authority"), None);
}
#[test]
fn test_origin_is_rebuilt_when_the_server_moves() {
let moved = SiteOrigin::new("http://127.0.0.1:5301/");
assert_eq!(
moved.decide("http://127.0.0.1:5301/docs/"),
NavigationDecision::Proceed
);
assert_eq!(
moved.decide("http://127.0.0.1:5220/docs/"),
NavigationDecision::OpenExternally
);
}
#[test]
fn test_unreadable_origin_keeps_navigations_in_window() {
let unknown = SiteOrigin::new("not a url");
assert_eq!(
unknown.decide("https://example.com/"),
NavigationDecision::Proceed
);
assert_eq!(
unknown.decide("javascript:void(0)"),
NavigationDecision::Block
);
}
}