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, Cmd/Ctrl+R (+Shift), Ctrl+F5, view-source and print shortcuts.
36    pub block_reload: bool,
37    /// Block Cmd/Ctrl +/-/0, ctrl+wheel and trackpad pinch zoom.
38    pub block_zoom: bool,
39    /// Block Alt+Left/Right, Cmd+[ / ], browser back/forward keys and mouse buttons 4/5.
40    pub block_history_nav: bool,
41    /// A dropped file must never navigate the webview; only `data-rk-dropzone` accepts drops.
42    pub block_drop_navigation: bool,
43    /// Route http(s)/mailto link clicks and `window.open` to the system browser.
44    pub intercept_external_links: bool,
45}
46
47impl Default for Hardening {
48    fn default() -> Self {
49        Self {
50            block_context_menu: true,
51            allow_editable_menu: true,
52            block_reload: true,
53            block_zoom: true,
54            block_history_nav: true,
55            block_drop_navigation: true,
56            intercept_external_links: true,
57        }
58    }
59}
60
61#[derive(Serialize)]
62#[serde(rename_all = "camelCase")]
63struct ScriptConfig {
64    block_context_menu: bool,
65    allow_editable_menu: bool,
66    block_reload: bool,
67    block_zoom: bool,
68    block_history_nav: bool,
69    content_devtools_labels: Vec<String>,
70    block_drop_navigation: bool,
71    intercept_external_links: bool,
72}
73
74const SCRIPT_BODY: &str = include_str!("harden.js");
75
76impl Hardening {
77    /// The initialisation script for this configuration (idempotent per document). DevTools
78    /// shortcuts are always cancelled; see [`Hardening::script_for`] for content webviews.
79    pub fn script(&self) -> String {
80        self.script_for(&[])
81    }
82
83    /// Like [`Hardening::script`], for an app whose `DevToolsPolicy::ContentOnly` registered the
84    /// webviews in `content_labels`. In those webviews the script leaves the context menu and the
85    /// DevTools shortcuts alone (the native layer decides what they may do); everywhere else
86    /// F12, Ctrl+Shift+I/J/C and Cmd+Opt+I/J/C/U are cancelled.
87    pub fn script_for(&self, content_labels: &[String]) -> String {
88        let cfg = ScriptConfig {
89            block_context_menu: self.block_context_menu,
90            allow_editable_menu: self.allow_editable_menu,
91            block_reload: self.block_reload,
92            block_zoom: self.block_zoom,
93            block_history_nav: self.block_history_nav,
94            content_devtools_labels: content_labels.to_vec(),
95            block_drop_navigation: self.block_drop_navigation,
96            intercept_external_links: self.intercept_external_links,
97        };
98        let json = serde_json::to_string(&cfg).expect("static config serialises");
99        SCRIPT_BODY.replace("__RK_SHELL_CONFIG__", &json)
100    }
101}
102
103/// Apply the native (non-DOM, non-DevTools) hardening to a freshly created webview. The DevTools
104/// settings are applied separately and unconditionally by [`crate::devtools::apply_native`];
105/// `decision` only tells this layer to leave a content webview's context menu to that layer.
106pub fn apply_native<R: Runtime>(webview: &Webview<R>, h: &Hardening, decision: WebviewDecision) {
107    #[cfg(target_os = "macos")]
108    macos::apply(webview, h);
109    #[cfg(windows)]
110    windows_impl::apply(webview, h, decision);
111    #[cfg(not(windows))]
112    let _ = decision;
113    #[cfg(not(any(target_os = "macos", windows)))]
114    let _ = (webview, h);
115}
116
117#[cfg(target_os = "macos")]
118mod macos {
119    use super::*;
120    use objc2::msg_send;
121    use objc2::runtime::AnyObject;
122
123    pub fn apply<R: Runtime>(webview: &Webview<R>, h: &Hardening) {
124        let zoom_off = h.block_zoom;
125        let nav_off = h.block_history_nav;
126        let _ = webview.with_webview(move |platform| unsafe {
127            // SAFETY: Tauri hands over the live WKWebView for this webview; used only inside this
128            // main-thread closure.
129            let wk = &*(platform.inner() as *mut AnyObject);
130            if zoom_off {
131                let (): () = msg_send![wk, setAllowsMagnification: false];
132            }
133            if nav_off {
134                let (): () = msg_send![wk, setAllowsBackForwardNavigationGestures: false];
135            }
136        });
137    }
138}
139
140#[cfg(windows)]
141mod windows_impl {
142    use super::*;
143    use crate::winapi::webview2_com::ContextMenuRequestedEventHandler;
144    use crate::winapi::webview2_com::Microsoft::Web::WebView2::Win32::{
145        ICoreWebView2Settings5, ICoreWebView2Settings6, ICoreWebView2_11,
146    };
147    use crate::winapi::windows::core::Interface;
148    use crate::winapi::windows::core::BOOL;
149
150    /// WebView2 side of the same policy. NOT compiled on this (macOS) host: compile and verify on
151    /// the Windows agent with `rightkit cargo check -p rightkit-tauri-shell` (see API.md).
152    pub fn apply<R: Runtime>(webview: &Webview<R>, h: &Hardening, decision: WebviewDecision) {
153        let h = h.clone();
154        let content = decision != WebviewDecision::Deny;
155        // F5, Ctrl+R, Alt+Left and the zoom keys are stopped one by one by the shell's single key
156        // handler (the DevTools layer adds it); browser keys are never turned off as a whole, so
157        // print and find keep working.
158        crate::devtools::set_hardening_keys(
159            webview.label(),
160            h.block_reload,
161            h.block_history_nav,
162            h.block_zoom,
163        );
164        let _ = webview.with_webview(move |platform| unsafe {
165            let Ok(core) = platform.controller().CoreWebView2() else {
166                return;
167            };
168            let Ok(settings) = core.Settings() else {
169                return;
170            };
171            let _ = settings.SetIsStatusBarEnabled(false);
172            if h.block_zoom {
173                let _ = settings.SetIsZoomControlEnabled(false);
174                if let Ok(s5) = settings.cast::<ICoreWebView2Settings5>() {
175                    let _ = s5.SetIsPinchZoomEnabled(false);
176                }
177            }
178            if h.block_history_nav {
179                if let Ok(s6) = settings.cast::<ICoreWebView2Settings6>() {
180                    let _ = s6.SetIsSwipeNavigationEnabled(false);
181                }
182            }
183            if h.block_context_menu && !content {
184                // Content webviews: the DevTools layer owns their menu. Keep default menus only so the editable-field menu can survive; every other
185                // target is marked handled (no menu shown).
186                let allow_editable = h.allow_editable_menu;
187                if let Ok(wv11) = core.cast::<ICoreWebView2_11>() {
188                    let handler =
189                        ContextMenuRequestedEventHandler::create(Box::new(move |_sender, args| {
190                            let Some(args) = args else { return Ok(()) };
191                            let target = args.ContextMenuTarget()?;
192                            let mut editable = BOOL(0);
193                            let _ = target.IsEditable(&mut editable);
194                            if !(allow_editable && editable.as_bool()) {
195                                args.SetHandled(true)?;
196                            }
197                            Ok(())
198                        }));
199                    let mut token: i64 = 0;
200                    let _ = wv11.add_ContextMenuRequested(&handler, &mut token);
201                }
202            }
203        });
204    }
205}