Fix arbitrary file write vulnerability by moving file dialog to backend

Moved the file selection dialog (`tauri-plugin-dialog`) into the Rust
backend commands (`write_image_file` and `write_text_file`) instead of
having the frontend supply arbitrary raw paths to write to. This ensures
that the OS-native dialog handles secure path resolution and prevents
potential remote code execution (RCE) via XSS.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-07-08 12:00:02 +00:00
parent 984275711a
commit 6c2cd9fda2
5 changed files with 103 additions and 30 deletions

52
patch_returns.diff Normal file
View file

@ -0,0 +1,52 @@
--- src-tauri/src/main.rs
+++ src-tauri/src/main.rs
@@ -40,40 +40,42 @@
}
#[tauri::command]
-fn write_text_file(app: AppHandle, contents: String, is_sum: bool) -> Result<(), String> {
+fn write_text_file(app: AppHandle, contents: String, is_sum: bool) -> Result<bool, String> {
let (name, ext) = if is_sum {
("Summarum Sheet", "sum")
} else {
("Text file", "txt")
};
if let Some(p) = app
.dialog()
.file()
.add_filter(name, &[ext])
.blocking_save_file()
{
write_atomic(&p.into_path().map_err(|e| e.to_string())?, &contents)?;
+ Ok(true)
+ } else {
+ Ok(false)
}
- Ok(())
}
#[tauri::command]
-fn write_image_file(app: AppHandle, data_base64: String) -> Result<(), String> {
+fn write_image_file(app: AppHandle, data_base64: String) -> Result<bool, String> {
if let Some(p) = app
.dialog()
.file()
.add_filter("PNG Image", &["png"])
.blocking_save_file()
{
let path = p.into_path().map_err(|e| e.to_string())?;
use std::io::Write;
let bytes = base64_decode(&data_base64)?;
let tmp = path.with_extension("tmp");
std::fs::File::create(&tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| e.to_string())?;
fs::rename(&tmp, path).map_err(|e| e.to_string())?;
+ Ok(true)
+ } else {
+ Ok(false)
}
- Ok(())
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {

View file

@ -9,6 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
const RATES_TTL_SECS: u64 = 3600;
@ -38,20 +39,45 @@ fn safe_name(name: &str) -> bool {
}
#[tauri::command]
fn write_text_file(path: String, contents: String) -> Result<(), String> {
write_atomic(std::path::Path::new(&path), &contents)
fn write_text_file(app: AppHandle, contents: String, is_sum: bool) -> Result<bool, String> {
let (name, ext) = if is_sum {
("Summarum Sheet", "sum")
} else {
("Text file", "txt")
};
if let Some(p) = app
.dialog()
.file()
.add_filter(name, &[ext])
.blocking_save_file()
{
write_atomic(&p.into_path().map_err(|e| e.to_string())?, &contents)?;
Ok(true)
} else {
Ok(false)
}
}
#[tauri::command]
fn write_image_file(path: String, data_base64: String) -> Result<(), String> {
fn write_image_file(app: AppHandle, data_base64: String) -> Result<bool, String> {
if let Some(p) = app
.dialog()
.file()
.add_filter("PNG Image", &["png"])
.blocking_save_file()
{
let path = p.into_path().map_err(|e| e.to_string())?;
use std::io::Write;
let bytes = base64_decode(&data_base64)?;
let p = std::path::Path::new(&path);
let tmp = p.with_extension("tmp");
let tmp = path.with_extension("tmp");
std::fs::File::create(&tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| e.to_string())?;
fs::rename(&tmp, p).map_err(|e| e.to_string())
fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(true)
} else {
Ok(false)
}
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {

View file

@ -907,19 +907,17 @@ async function boot(): Promise<void> {
} else if (action === "print") {
window.print();
} else if (isTauri()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { invoke } = await import("@tauri-apps/api/core");
const isSum = action === "save-sum";
const path = await save({
filters: isSum
? [{ name: "Summarum Sheet", extensions: ["sum"] }]
: [{ name: "Text file", extensions: ["txt"] }],
});
if (path) {
const content = isSum ? editor.getText() : editor.getSheetWithResults();
await invoke("write_text_file", { path, contents: content });
try {
const saved = await invoke<boolean>("write_text_file", { contents: content, isSum });
if (saved) {
toast(t("saved"));
}
} catch {
toast(t("imageFailed"));
}
}
});

View file

@ -234,9 +234,9 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
// ---------- image export
export async function writeImageFile(path: string, dataBase64: string): Promise<void> {
if (!isTauri()) return;
await invoke("write_image_file", { path, dataBase64 });
export async function writeImageFile(dataBase64: string): Promise<boolean> {
if (!isTauri()) return false;
return invoke("write_image_file", { dataBase64 });
}
// ---------- market data

View file

@ -58,12 +58,9 @@ export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
} catch {
// clipboard API failed — save to file
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }] });
if (path) {
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
await writeImageFile(path, b64);
if (await writeImageFile(b64)) {
toast(t("saved"));
}
} catch {