From 97760236a7ec41ae82684c3fba364a22be3be113 Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:05:22 +0200 Subject: [PATCH 1/4] fix(shell): refuse foreign requests at the network layer on Windows Candidate QA of 0.3.0 on Windows 11 (WebView2 154) found that the navigation pin keeps the page in the app but not the data. location.href, location.assign, a link click and a meta refresh each sent a real navigation request (sec-fetch-mode: navigate) carrying the URL to a foreign server, even though NavigationStarting was cancelled and the app logged the refusal. wry cancels synchronously; WebView2 had already sent the request. WKWebView and WebKitGTK sent nothing in the same probe. request_pin (Windows only) adds a "*" WebResourceRequested filter on every webview as it becomes ready and answers each web request (http, https, ws, wss) to a host other than the app's asset or IPC origin with a local 403, before it leaves the machine. data:, blob: and custom schemes are local and pass; an unparseable URI is refused. wry's custom-protocol handler ignores URIs it does not own, so the two coexist. The release workflow now also runs on PRs that touch src-tauri/ or viewer-dist/: ci.yml builds on macOS only, so Windows- and Linux-only shell code otherwise first compiled at release time. webview2-com and windows were already in the lock under wry at the same versions; only the root package's dependency list changed. --- .github/workflows/release.yml | 7 +- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 5 ++ src-tauri/src/lib.rs | 118 ++++++++++++++++++++++++++++++++++ src-tauri/src/request_pin.rs | 73 +++++++++++++++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/request_pin.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11bb540..3564b7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,8 @@ name: Release # GitHub release for a v* tag. Publishing the draft stays a manual maintainer # step. Nothing here restores a cache: every package comes from a clean runner, # so a poisoned cache can never reach a release. Pull requests that touch the -# pipeline run the build jobs only. +# pipeline or the shell run the build jobs only; ci.yml builds on macOS alone, +# so this is where Windows- and Linux-only shell code first compiles. on: push: tags: ['v*'] @@ -14,8 +15,8 @@ on: - .github/workflows/release.yml - scripts/release/** - scripts/sync-viewer.mjs - - src-tauri/tauri*.conf.json - - viewer-dist/manifest.json + - src-tauri/** + - viewer-dist/** concurrency: group: release-${{ github.ref }} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e4b7653..2b40da3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3166,6 +3166,8 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-wdio-webdriver", "tokio", + "webview2-com", + "windows", "windows-sys 0.60.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c57664..0441477 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,6 +47,11 @@ objc2 = "0.6.4" libc = "0.2" [target.'cfg(windows)'.dependencies] +# The request pin (src/request_pin.rs) talks to WebView2 directly. Both crates +# are already in the lock underneath wry at these versions, so this adds no +# package to the release graph. +webview2-com = "0.38" +windows = "0.61" # File IDs and change times from the same handle that supplied the bytes. windows-sys = { version = "0.60", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Shutdown", "Win32_System_Threading"] } percent-encoding = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9bc75fa..32dc35b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,8 @@ mod native; #[cfg(target_os = "linux")] mod linux_session; #[cfg(windows)] +mod request_pin; +#[cfg(windows)] mod windows_file_info; #[cfg(windows)] mod windows_session; @@ -157,14 +159,61 @@ fn is_app_navigation(target: &tauri::Url, allowed: &[tauri::Url]) -> bool { }) } +/// The origins a webview may send network requests to: the asset origin and, +/// on Windows, the IPC endpoint the bridge fetches (`connect-src` lists it). Only +/// `request_pin` consults this; `the_request_pin_admits_what_the_csp_connects_to` +/// holds it to the CSP. +// Only `request_pin` (Windows) calls these three; the tests run everywhere. +#[cfg_attr(not(windows), allow(dead_code))] +fn app_request_origins() -> Vec { + vec![ + served_origin(), + tauri::Url::parse("http://ipc.localhost").expect("static origin parses"), + ] +} + +/// SECURITY: whether a webview request may reach the network. Only the web +/// schemes leave the machine, and those must target an app origin; `data:`, +/// `blob:` and the app's own custom schemes are local and pass. A URI that does +/// not parse is refused. +#[cfg_attr(not(windows), allow(dead_code))] +fn is_app_request(target: &str, allowed: &[tauri::Url]) -> bool { + let Ok(url) = tauri::Url::parse(target) else { + return false; + }; + match url.scheme() { + "http" | "https" | "ws" | "wss" => is_app_navigation(&url, allowed), + _ => true, + } +} + +/// `scheme://host` of a refused URL for the log. The rest is whatever the page +/// tried to smuggle out, and it does not belong in a log. +#[cfg_attr(not(windows), allow(dead_code))] +fn origin_for_log(target: &str) -> String { + match tauri::Url::parse(target) { + Ok(url) => format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")), + Err(_) => "an unparseable URI".into(), + } +} + /// Installs `is_app_navigation` on EVERY webview, the config-declared window and /// the `db-` windows alike: a plugin's navigation hook runs for each webview /// the app creates, so no window can be built without it. In a `tauri dev` build /// the page comes from `devUrl` instead (tauri's `get_app_url`), so that origin is /// admitted there and only there. `window.open` needs no hook — with no new-window /// handler installed, wry refuses every new-window request on all three platforms. +/// +/// On Windows the navigation hook is not enough on its own: WebView2 has already +/// sent a refused navigation's request when the cancel lands, so `request_pin` +/// also refuses foreign requests at the network layer, installed on each webview +/// as it becomes ready. fn navigation_pin() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("navigation-pin") + .on_webview_ready(|_webview| { + #[cfg(windows)] + install_request_pin(&_webview); + }) .on_navigation(|webview, url| { let mut allowed = vec![served_origin()]; if tauri::is_dev() { @@ -185,6 +234,26 @@ fn navigation_pin() -> tauri::plugin::TauriPlugin { .build() } +/// A webview without the request pin still has the navigation pin, so a failure +/// here is reported, not fatal: closing the window would leave the app unusable +/// on a WebView2 that lacks an API this needs. +#[cfg(windows)] +fn install_request_pin(webview: &tauri::Webview) { + let mut allowed = app_request_origins(); + if tauri::is_dev() { + allowed.extend(webview.config().build.dev_url.clone()); + } + let label = webview.label().to_string(); + let reached = webview.with_webview(move |platform| { + if let Err(e) = request_pin::install(platform.controller(), platform.environment(), allowed) { + eprintln!("could not install the request pin on webview {label}: {e}"); + } + }); + if let Err(e) = reached { + eprintln!("could not reach webview {} to install the request pin: {e}", webview.label()); + } +} + /// Source of `db-` labels. Monotonic for the life of the process — see /// `NEW_WINDOW_LABEL_PREFIX`. static WINDOW_SEQ: AtomicU64 = AtomicU64::new(0); @@ -4315,6 +4384,55 @@ mod tests { assert!(live.contains(".plugin(navigation_pin())")); } + /// WebView2 sends a refused navigation's request anyway, so on Windows the + /// request pin must refuse every web request off the app's origins, in any + /// form a page can issue one, while leaving local schemes alone. + #[test] + fn the_request_pin_refuses_web_requests_off_the_app_origins() { + let url = |text: &str| tauri::Url::parse(text).expect("test URL parses"); + let windows = [url("http://tauri.localhost"), url("http://ipc.localhost")]; + for allowed in [ + "http://tauri.localhost/viewer.html", + "http://tauri.localhost/viewer.html?nav=control", + "http://ipc.localhost/plugin%3Aevent%7Clisten", + "data:image/png;base64,iVBORw0KGgo=", + "blob:http://tauri.localhost/2a7c1f80-5d1e-4b7e-9c55-8d1f3b0a6e21", + ] { + assert!(is_app_request(allowed, &windows), "{allowed}"); + } + for refused in [ + "http://127.0.0.1:64912/nav-href?leak=1", + "https://attacker.example/?leak=1", + "ws://attacker.example/socket", + "wss://attacker.example/socket", + "http://tauri.localhost.attacker.example/", + "http://ipc.localhost:8080/", + "https://tauri.localhost/viewer.html", + "not a uri", + ] { + assert!(!is_app_request(refused, &windows), "{refused}"); + } + assert_eq!(origin_for_log("http://127.0.0.1:64912/nav?leak=secret"), "http://127.0.0.1"); + assert!(!origin_for_log("https://attacker.example/?leak=secret").contains("secret")); + } + + /// The request pin must admit every origin the CSP lets the page connect to, + /// or the bridge's own IPC fetches would be refused on Windows. + #[test] + fn the_request_pin_admits_what_the_csp_connects_to() { + let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json parses"); + let connect = conf["app"]["security"]["csp"]["connect-src"].as_str().expect("connect-src"); + let origins = app_request_origins(); + for source in connect.split_whitespace().filter(|s| s.starts_with("http")) { + let origin = tauri::Url::parse(source).expect("connect-src origin parses"); + assert!(origins.contains(&origin), "{source} is in connect-src but not admitted"); + } + let source = include_str!("lib.rs"); + let live = &source[..source.find("#[cfg(test)]\nmod tests").expect("tests module")]; + assert!(live.contains(".on_webview_ready(") && live.contains("install_request_pin(&_webview)")); + } + // -- Unsaved changes are not discarded without asking ------------------- /// The per-window decision. A window with dirty databases must not close on diff --git a/src-tauri/src/request_pin.rs b/src-tauri/src/request_pin.rs new file mode 100644 index 0000000..be89ff5 --- /dev/null +++ b/src-tauri/src/request_pin.rs @@ -0,0 +1,73 @@ +//! SECURITY (Windows only): refuse every webview request to a host other than +//! the app's own before it reaches the network. +//! +//! `navigation_pin` cancels a navigation off the app's origin in WebView2's +//! `NavigationStarting`, and the page stays put, but WebView2 has already sent +//! that navigation's request by then. Measured on WebView2 154, `location.href`, +//! `location.assign`, a link click and a meta refresh each delivered their URL, +//! query string included, to a foreign server despite the cancel, so a +//! compromised page could still exfiltrate through the URL. `WebResourceRequested` +//! is raised before a request goes out; answering foreign requests there with a +//! local 403 means they never leave the machine. WKWebView and WebKitGTK do not +//! send a cancelled navigation's request, so this layer is Windows-only. +//! +//! wry registers its own `WebResourceRequested` handler for the app's custom +//! protocols and ignores every URI that is not its own, so the `*` filter here +//! coexists with it: requests for the app's own origins are left untouched for +//! wry to serve. + +use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2Controller, ICoreWebView2Environment, ICoreWebView2_22, + COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL, COREWEBVIEW2_WEB_RESOURCE_REQUEST_SOURCE_KINDS_ALL, +}; +use webview2_com::{take_pwstr, WebResourceRequestedEventHandler}; +use windows::core::{Interface, HSTRING, PWSTR}; + +/// Install the filter and handler on one webview. `allowed` is +/// `crate::app_request_origins` (plus `devUrl` in a `tauri dev` build). +pub fn install( + controller: ICoreWebView2Controller, + environment: ICoreWebView2Environment, + allowed: Vec, +) -> windows::core::Result<()> { + // SAFETY: WebView2 COM calls on interfaces Tauri handed us, made on the + // webview's own thread (`with_webview` runs there), as wry itself does. + unsafe { + let webview = controller.CoreWebView2()?; + let filter = HSTRING::from("*"); + // The newer API also covers requests from iframes and shared workers, + // which the original filter misses (WebView2Feedback#1114). + if let Ok(webview_22) = webview.cast::() { + webview_22.AddWebResourceRequestedFilterWithRequestSourceKinds( + &filter, + COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL, + COREWEBVIEW2_WEB_RESOURCE_REQUEST_SOURCE_KINDS_ALL, + )?; + } else { + webview.AddWebResourceRequestedFilter(&filter, COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)?; + } + let mut token = 0i64; + webview.add_WebResourceRequested( + &WebResourceRequestedEventHandler::create(Box::new(move |_, args| { + let Some(args) = args else { + return Ok(()); + }; + let mut uri = PWSTR::null(); + args.Request()?.Uri(&mut uri)?; + let uri = take_pwstr(uri); + if crate::is_app_request(&uri, &allowed) { + return Ok(()); + } + eprintln!("refused a request away from the app to {}", crate::origin_for_log(&uri)); + let response = environment.CreateWebResourceResponse( + None, + 403, + &HSTRING::from("Forbidden"), + &HSTRING::new(), + )?; + args.SetResponse(&response) + })), + &mut token, + ) + } +} From 5945617c9e44f78793090570573feed94d2356c4 Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:05:33 +0200 Subject: [PATCH 2/4] release: 0.3.1 0.3.0 was drafted but never published: Windows candidate QA found the navigation pin did not stop the request carrying a refused navigation's URL. 0.3.1 adds the Windows request pin. --- README.md | 2 +- docs/release-readiness.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1beec35..dd1da72 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Linux and Windows. It shares its viewer with the [SQLite Explorer VS Code extension](https://github.com/zknpr/SQLite-Explorer) and runs in a Tauri desktop shell. -The current desktop version is **0.3.0**, integrating the extension's **1.8.1** +The current desktop version is **0.3.1**, integrating the extension's **1.8.1** release and desktop-specific fixes. Packages for macOS, Linux and Windows are attached to each [release](https://github.com/zknpr/SQLite-Explorer-App/releases); see [Install](#install). See [release readiness](docs/release-readiness.md) for diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 3a4d747..21eb405 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,6 +1,6 @@ # Release readiness -Status checked October 8, 2026, for desktop 0.3.0 and the extension 1.8.1 +Status checked October 8, 2026, for desktop 0.3.1 and the extension 1.8.1 integration. The source is public under MIT. Release packages are built by the [release workflow](../.github/workflows/release.yml); the gates below separate source publication from binary distribution. diff --git a/package-lock.json b/package-lock.json index 81ffd7b..1193c59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sqlite-explorer-desktop", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sqlite-explorer-desktop", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.11.4", diff --git a/package.json b/package.json index a59a34c..daa0bc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sqlite-explorer-desktop", - "version": "0.3.0", + "version": "0.3.1", "description": "SQLite Explorer desktop shell (Tauri v2) around the extension's desktop viewer artifacts", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2b40da3..4b7d677 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3154,7 +3154,7 @@ dependencies = [ [[package]] name = "sqlite-explorer-desktop" -version = "0.3.0" +version = "0.3.1" dependencies = [ "libc", "objc2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0441477..294146b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlite-explorer-desktop" -version = "0.3.0" +version = "0.3.1" description = "SQLite Explorer desktop shell" edition = "2021" license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7550237..570a050 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "SQLite Explorer", - "version": "0.3.0", + "version": "0.3.1", "identifier": "xyz.zknpr.sqlite-explorer", "build": { "frontendDist": "../viewer-dist" From 2eb9d96fb34233b86e448d54f14cad5590239006 Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:21:21 +0200 Subject: [PATCH 3/4] docs: record the Windows request pin and the candidate QA results SECURITY.md and CLAUDE.md describe the WebView2 behaviour and the request pin. Release readiness records what each platform's probe measured, including the 0.3.0 Windows leak that kept that draft unpublished, and the installed-package smoke results of the candidates. --- CLAUDE.md | 10 ++++++++++ SECURITY.md | 5 ++++- docs/release-readiness.md | 4 ++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e379dc4..616aec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,16 @@ the worker, the `ModificationTracker` undo/redo history, and file I/O via the br not cover navigation, so without it a compromised page exfiltrates through `location`. A plugin hook runs for every webview, so `db-` windows cannot be built without it. `window.open` is already refused: no new-window handler is installed. +- **Windows request pin** (`request_pin.rs`): WebView2 SENDS a refused navigation's request. + `NavigationStarting` is cancelled synchronously and the page stays, but `location.href`, + `location.assign`, link clicks and meta refresh still delivered their URL to a foreign + server (measured on WebView2 154; WKWebView and WebKitGTK sent nothing). So the same + plugin's `on_webview_ready` installs a `*` `WebResourceRequested` filter that answers + every web request (http/https/ws/wss) off `app_request_origins` with a local 403. + `data:`/`blob:`/custom schemes pass. wry's custom-protocol handler ignores foreign URIs, + so the two coexist. `the_request_pin_admits_what_the_csp_connects_to` keeps IPC admitted. + Verify Windows navigation claims by counting hits on a foreign listener, never by + checking whether the page moved. - **Capabilities are the real boundary** (`withGlobalTauri` hands the page the full `__TAURI__` surface, so `bridge.js` is hygiene, not a boundary). The set is exactly `core:event:allow-listen` + `allow-unlisten` (all the bridge's `listen` needs; every diff --git a/SECURITY.md b/SECURITY.md index 0b99bd8..40daf34 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,7 +59,10 @@ Out of scope: - Writes are atomic: an exclusive temporary file, `fsync`, then rename. - The CSP sets `default-src 'none'`, an eval-free `script-src`, and `base-uri`, `form-action` and `frame-ancestors` to `'none'`. -- A navigation handler keeps every webview on the app's own origin. +- A navigation handler keeps every webview on the app's own origin. On + Windows, WebView2 sends a cancelled navigation's request anyway, so a request + filter also refuses every web request to a host other than the app's own + before it leaves the machine. - The native engine refuses `ATTACH`/`DETACH`, and the shell re-checks the bound file's identity around every native call. diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 21eb405..569d0a3 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -37,13 +37,13 @@ downloaded package or close the macOS interaction gates. | Publicly retrievable viewer source pin | Public; fresh-clone sync verified for three targets as above | | Security policy | [SECURITY.md](../SECURITY.md), private vulnerability reporting | | Continuous integration | Apple Silicon macOS: manifest verification, script tests, shell unit tests, clippy. No Linux or Windows lane | -| Navigation pin and CSP `base-uri`/`form-action`/`frame-ancestors` | Live-verified in an Apple Silicon macOS QA build against a pre-change control build. Linux and Windows runtime behaviour unverified | +| Navigation pin and CSP `base-uri`/`form-action`/`frame-ancestors` | Verified against a foreign listener on installed packages. macOS: QA build against a pre-change control. Linux 0.3.0 `.deb`: no request left. Windows 0.3.0: the page stayed, but WebView2 still sent every refused navigation's request, so 0.3.0 was never published. Windows 0.3.1 adds the request pin: zero foreign requests for `location.href`/`assign`, link click and meta refresh, and `window.open` sends nothing | | Release pipeline | Builds macOS, Linux and Windows packages on their own OS from the viewer pin, with no cache, and drafts a release for a `v*` tag; a prerelease version drafts a prerelease. Each asset gets SHA256SUMS and a build-provenance attestation. Publishing is manual | | Third-party notices | Bundled inside every package; the release also attaches them with the MPL Cargo sources, which are checksum-verified against Cargo.lock | | AppImage distribution | Not distributed: its extra bundled Linux system libraries have no notice/source inventory. Linux ships as `.deb` | | Linux glibc floor | The native engine needs glibc 2.38 and the app built on Ubuntu 24.04 needs 2.39; the `.deb` declares `libc6 (>= 2.39)` and the workflow fails if any packaged binary needs more | | Final macOS installed-package checks | Open cases and older tested source are recorded in [macOS QA](macos-release-qa.md); rerun against the final candidate | -| Linux/Windows installed-package checks | Passed for the packages and environments in [Linux/Windows QA](linux-windows-release-qa.md), including real shutdown/restart. Those packages predate the navigation pin and CSP change; rerun the affected checks on new packages | +| Linux/Windows installed-package checks | The 0.2.0 packages passed the checks in [Linux/Windows QA](linux-windows-release-qa.md), including real shutdown/restart. Candidates: Linux 0.3.0 `.deb` (upgraded in place from 0.2.0) passed the 6 installed-app smoke checks; Windows 0.3.1 NSIS (upgraded from 0.3.0) passed them with the request pin active. Shutdown/restart was not rerun on these candidates | | Other Linux desktops, Wayland and physical keyboards | Unverified; limit platform claims to observed coverage | | Windows distribution | Unsigned. SignPath Foundation signing requires a prior public release, so the first release ships unsigned, then apply. Signing will cover the app and installer, not the upstream `tjs.exe`. Smart App Control blocks the unsigned installer; whether it blocks `tjs.exe` under a signed app (the app would fall back to WASM) is untested. SmartScreen on a clean machine unverified | | macOS distribution | The app has an ad-hoc signature with the hardened runtime, built by the workflow; the DMG is unsigned. No Developer ID signing or notarization, by choice. Gatekeeper's Open Anyway flow on a downloaded package unverified | From 1bdbab44cbd9c12a7fbf0dab1c6e04bcd02c2364 Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:32:30 +0200 Subject: [PATCH 4/4] fix(shell): the request pin admits only local schemes, refusing file: (UNC) Codex review (P1): is_app_request passed every scheme other than http/https/ws/wss. On Windows a file://host/share URI is a UNC path, an SMB connection to that host that can also hand it the user's NTLM credentials, so a compromised page could have used it to keep the exfiltration channel open. Only data:, blob: and about: pass now; every other scheme is refused. The app's own custom protocols arrive as http://.localhost on Windows, so nothing legitimate is lost. Not measured: whether WebView2 would have made the SMB connection at all (Chromium normally refuses web-origin navigations to file:). The allowlist closes it either way. The regression test (file://attacker.example/share/x, file:///C:/..., ftp:, chrome-extension:) failed before this change and passes after it. --- CLAUDE.md | 3 ++- src-tauri/src/lib.rs | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 616aec9..e802158 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,8 @@ the worker, the `ModificationTracker` undo/redo history, and file I/O via the br server (measured on WebView2 154; WKWebView and WebKitGTK sent nothing). So the same plugin's `on_webview_ready` installs a `*` `WebResourceRequested` filter that answers every web request (http/https/ws/wss) off `app_request_origins` with a local 403. - `data:`/`blob:`/custom schemes pass. wry's custom-protocol handler ignores foreign URIs, + Only `data:`/`blob:`/`about:` pass; every other scheme is refused, `file:` included, + because a `file://host/` URI is a UNC/SMB connection that can leak NTLM credentials. wry's custom-protocol handler ignores foreign URIs, so the two coexist. `the_request_pin_admits_what_the_csp_connects_to` keeps IPC admitted. Verify Windows navigation claims by counting hits on a foreign listener, never by checking whether the page moved. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32dc35b..ab05e55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -172,10 +172,14 @@ fn app_request_origins() -> Vec { ] } -/// SECURITY: whether a webview request may reach the network. Only the web -/// schemes leave the machine, and those must target an app origin; `data:`, -/// `blob:` and the app's own custom schemes are local and pass. A URI that does -/// not parse is refused. +/// SECURITY: whether a webview request may proceed. Web schemes must target an +/// app origin. Only schemes whose bytes come from the page itself (`data:`, +/// `blob:`, `about:`) pass. Every other scheme is refused, because "not a web +/// scheme" does not mean local: on Windows a `file://host/…` URI is a UNC path, +/// an SMB connection to that host that can also hand it the user's NTLM +/// credentials. On Windows the app's own custom protocols arrive as +/// `http://.localhost`, so nothing legitimate is lost. A URI that does not +/// parse is refused. #[cfg_attr(not(windows), allow(dead_code))] fn is_app_request(target: &str, allowed: &[tauri::Url]) -> bool { let Ok(url) = tauri::Url::parse(target) else { @@ -183,7 +187,8 @@ fn is_app_request(target: &str, allowed: &[tauri::Url]) -> bool { }; match url.scheme() { "http" | "https" | "ws" | "wss" => is_app_navigation(&url, allowed), - _ => true, + "data" | "blob" | "about" => true, + _ => false, } } @@ -4409,9 +4414,16 @@ mod tests { "http://ipc.localhost:8080/", "https://tauri.localhost/viewer.html", "not a uri", + // On Windows a hosted file: URI is a UNC path: an SMB connection to + // that host, which can also hand it the user's NTLM credentials. + "file://attacker.example/share/x", + "file:///C:/Windows/win.ini", + "ftp://attacker.example/x", + "chrome-extension://abcdefghijklmnop/x", ] { assert!(!is_app_request(refused, &windows), "{refused}"); } + assert!(is_app_request("about:blank", &windows)); assert_eq!(origin_for_log("http://127.0.0.1:64912/nav?leak=secret"), "http://127.0.0.1"); assert!(!origin_for_log("https://attacker.example/?leak=secret").contains("secret")); }