Skip to main content

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}