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/CLAUDE.md b/CLAUDE.md index e379dc4..e802158 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,17 @@ 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. + 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. - **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/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/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 3a4d747..569d0a3 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. @@ -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 | 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 e4b7653..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", @@ -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..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" @@ -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..ab05e55 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,66 @@ 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 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 { + return false; + }; + match url.scheme() { + "http" | "https" | "ws" | "wss" => is_app_navigation(&url, allowed), + "data" | "blob" | "about" => true, + _ => false, + } +} + +/// `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 +239,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 +4389,62 @@ 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", + // 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")); + } + + /// 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, + ) + } +} 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"