rightkit_tauri_shell/harden.rs
1//! Webview hardening: no stray webview context menu, reload / zoom /
2//! history keys blocked, drag-drop cannot navigate, external links leave for the system browser.
3//!
4//! Three layers, because no single one covers every engine:
5//! 1. an initialisation script (all frames by default) that cancels the DOM events;
6//! 2. native webview settings (WKWebView preferences, WebView2 settings) for what JavaScript
7//! cannot cancel (Cmd+Opt+I inspector, WebView2 accelerator keys, pinch zoom, swipe);
8//! 3. the Rust navigation hook (`NavPolicy`) that catches whatever navigates anyway.
9
10use serde::Serialize;
11use tauri::{Runtime, Webview};
12
13use crate::devtools::WebviewDecision;
14
15/// Frame scope for the full shell plugin's DOM hardening script.
16/// Native webview settings & Rust navigation policy are configured separately.
17#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
18pub enum HardeningScript {
19 /// Inject into every frame, including document iframes (legacy default).
20 #[default]
21 AllFrames,
22 /// Inject into the top frame of each webview only.
23 TopFrameOnly,
24 /// Do not inject the DOM hardening script.
25 Off,
26}
27
28#[derive(Debug, Clone)]
29pub struct Hardening {
30 /// Cancel `contextmenu` outside designated surfaces.
31 pub block_context_menu: bool,
32 /// Keep the native Cut/Copy/Paste menu inside inputs, textareas and contenteditable
33 /// (HeardRight behaviour). Mark any other surface with `data-rk-native-menu`.
34 pub allow_editable_menu: bool,
35 /// Block F5 and Cmd/Ctrl+R (+Shift) reload shortcuts only. Print and view-source have
36 /// their own switches below, so an app can keep reload blocked without losing them.
37 pub block_reload: bool,
38 /// Block Cmd/Ctrl+P (print). Split from `block_reload`: MailRight needs print while
39 /// reload stays blocked.
40 pub block_print: bool,
41 /// Block Cmd/Ctrl+U (view source) outside editable surfaces. Inside an input, textarea or
42 /// contenteditable the same chord is the underline command and is never cancelled, so a
43 /// compose editor keeps underline while view-source stays blocked elsewhere. Inspector
44 /// shortcuts (F12, Cmd/Ctrl+Shift+I/J/C, Cmd+Opt+I/J/C) are not governed by this switch:
45 /// the DevTools layer always cancels them outside registered content webviews.
46 pub block_view_source: bool,
47 /// Block Cmd/Ctrl +/-/0, ctrl+wheel and trackpad pinch zoom.
48 pub block_zoom: bool,
49 /// Block Alt+Left/Right, Cmd+[ / ], browser back/forward keys and mouse buttons 4/5.
50 pub block_history_nav: bool,
51 /// A dropped file must never navigate the webview; only `data-rk-dropzone` accepts drops.
52 pub block_drop_navigation: bool,
53 /// Route http(s)/mailto link clicks and `window.open` to the system browser.
54 pub intercept_external_links: bool,
55}
56
57impl Default for Hardening {
58 fn default() -> Self {
59 Self {
60 block_context_menu: true,
61 allow_editable_menu: true,
62 block_reload: true,
63 // Compatible defaults: `block_reload` used to cover print and view-source too, so
64 // the split switches default on and `Hardening::default()` blocks exactly what it
65 // blocked before.
66 block_print: true,
67 block_view_source: true,
68 block_zoom: true,
69 block_history_nav: true,
70 block_drop_navigation: true,
71 intercept_external_links: true,
72 }
73 }
74}
75
76#[derive(Serialize)]
77#[serde(rename_all = "camelCase")]
78struct ScriptConfig {
79 block_context_menu: bool,
80 allow_editable_menu: bool,
81 block_reload: bool,
82 block_print: bool,
83 block_view_source: bool,
84 block_zoom: bool,
85 block_history_nav: bool,
86 content_devtools_labels: Vec<String>,
87 block_drop_navigation: bool,
88 intercept_external_links: bool,
89}
90
91const SCRIPT_BODY: &str = include_str!("harden.js");
92
93impl Hardening {
94 /// The initialisation script for this configuration (idempotent per document). DevTools
95 /// shortcuts are always cancelled; see [`Hardening::script_for`] for content webviews.
96 pub fn script(&self) -> String {
97 self.script_for(&[])
98 }
99
100 /// Like [`Hardening::script`], for an app whose `DevToolsPolicy::ContentOnly` registered the
101 /// webviews in `content_labels`. In those webviews the script leaves the context menu and the
102 /// DevTools shortcuts alone (the native layer decides what they may do); everywhere else
103 /// F12, Ctrl+Shift+I/J/C and Cmd+Opt+I/J/C/U are cancelled.
104 pub fn script_for(&self, content_labels: &[String]) -> String {
105 let cfg = ScriptConfig {
106 block_context_menu: self.block_context_menu,
107 allow_editable_menu: self.allow_editable_menu,
108 block_reload: self.block_reload,
109 block_print: self.block_print,
110 block_view_source: self.block_view_source,
111 block_zoom: self.block_zoom,
112 block_history_nav: self.block_history_nav,
113 content_devtools_labels: content_labels.to_vec(),
114 block_drop_navigation: self.block_drop_navigation,
115 intercept_external_links: self.intercept_external_links,
116 };
117 let json = serde_json::to_string(&cfg).expect("static config serialises");
118 SCRIPT_BODY.replace("__RK_SHELL_CONFIG__", &json)
119 }
120}
121
122/// Apply the native (non-DOM, non-DevTools) hardening to a freshly created webview. The DevTools
123/// settings are applied separately and unconditionally by [`crate::devtools::apply_native`];
124/// `decision` only tells this layer to leave a content webview's context menu to that layer.
125pub fn apply_native<R: Runtime>(webview: &Webview<R>, h: &Hardening, decision: WebviewDecision) {
126 #[cfg(target_os = "macos")]
127 macos::apply(webview, h);
128 #[cfg(windows)]
129 windows_impl::apply(webview, h, decision);
130 #[cfg(not(windows))]
131 let _ = decision;
132 #[cfg(not(any(target_os = "macos", windows)))]
133 let _ = (webview, h);
134}
135
136#[cfg(target_os = "macos")]
137mod macos {
138 use super::*;
139 use objc2::msg_send;
140 use objc2::runtime::AnyObject;
141
142 pub fn apply<R: Runtime>(webview: &Webview<R>, h: &Hardening) {
143 let zoom_off = h.block_zoom;
144 let nav_off = h.block_history_nav;
145 let _ = webview.with_webview(move |platform| unsafe {
146 // SAFETY: Tauri hands over the live WKWebView for this webview; used only inside this
147 // main-thread closure.
148 let wk = &*(platform.inner() as *mut AnyObject);
149 if zoom_off {
150 let (): () = msg_send![wk, setAllowsMagnification: false];
151 }
152 if nav_off {
153 let (): () = msg_send![wk, setAllowsBackForwardNavigationGestures: false];
154 }
155 });
156 }
157}
158
159#[cfg(windows)]
160mod windows_impl {
161 use super::*;
162 use crate::winapi::webview2_com::ContextMenuRequestedEventHandler;
163 use crate::winapi::webview2_com::Microsoft::Web::WebView2::Win32::{
164 ICoreWebView2Settings5, ICoreWebView2Settings6, ICoreWebView2_11,
165 };
166 use crate::winapi::windows::core::Interface;
167 use crate::winapi::windows::core::BOOL;
168
169 /// WebView2 side of the same policy. NOT compiled on this (macOS) host: compile and verify on
170 /// the Windows agent with `rightkit cargo check -p rightkit-tauri-shell` (see API.md).
171 pub fn apply<R: Runtime>(webview: &Webview<R>, h: &Hardening, decision: WebviewDecision) {
172 let h = h.clone();
173 let content = decision != WebviewDecision::Deny;
174 // F5, Ctrl+R, Alt+Left and the zoom keys are stopped one by one by the shell's single key
175 // handler (the DevTools layer adds it); browser keys are never turned off as a whole, so
176 // print and find keep working.
177 crate::devtools::set_hardening_keys(
178 webview.label(),
179 h.block_reload,
180 h.block_history_nav,
181 h.block_zoom,
182 );
183 let _ = webview.with_webview(move |platform| unsafe {
184 let Ok(core) = platform.controller().CoreWebView2() else {
185 return;
186 };
187 let Ok(settings) = core.Settings() else {
188 return;
189 };
190 let _ = settings.SetIsStatusBarEnabled(false);
191 if h.block_zoom {
192 let _ = settings.SetIsZoomControlEnabled(false);
193 if let Ok(s5) = settings.cast::<ICoreWebView2Settings5>() {
194 let _ = s5.SetIsPinchZoomEnabled(false);
195 }
196 }
197 if h.block_history_nav {
198 if let Ok(s6) = settings.cast::<ICoreWebView2Settings6>() {
199 let _ = s6.SetIsSwipeNavigationEnabled(false);
200 }
201 }
202 if h.block_context_menu && !content {
203 // Content webviews: the DevTools layer owns their menu. Keep default menus only so the editable-field menu can survive; every other
204 // target is marked handled (no menu shown).
205 let allow_editable = h.allow_editable_menu;
206 if let Ok(wv11) = core.cast::<ICoreWebView2_11>() {
207 let handler =
208 ContextMenuRequestedEventHandler::create(Box::new(move |_sender, args| {
209 let Some(args) = args else { return Ok(()) };
210 let target = args.ContextMenuTarget()?;
211 let mut editable = BOOL(0);
212 let _ = target.IsEditable(&mut editable);
213 if !(allow_editable && editable.as_bool()) {
214 args.SetHandled(true)?;
215 }
216 Ok(())
217 }));
218 let mut token: i64 = 0;
219 let _ = wv11.add_ContextMenuRequested(&handler, &mut token);
220 }
221 }
222 });
223 }
224}