Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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*']
Expand All @@ -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 }}
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>` 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/release-readiness.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
130 changes: 130 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<tauri::Url> {
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://<name>.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-<n>` 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<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
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() {
Expand All @@ -185,6 +239,26 @@ fn navigation_pin<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.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<R: tauri::Runtime>(webview: &tauri::Webview<R>) {
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-<n>` labels. Monotonic for the life of the process — see
/// `NEW_WINDOW_LABEL_PREFIX`.
static WINDOW_SEQ: AtomicU64 = AtomicU64::new(0);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading