fix arbitrary file write via write_text_file/write_image_file

Both Tauri commands took a raw path string straight from the frontend
and wrote to it with no validation — any code able to call invoke()
(a compromised or malicious extension script, which run with full page
privileges) could write to any path the OS user has access to, not just
one chosen through the save dialog. Move the save-file dialog into the
Rust command itself so the only path ever used is one the native dialog
returned; the frontend now only sends the content to save.
This commit is contained in:
Horizontal42 2026-07-08 15:50:13 +03:00
parent f2deaa1b94
commit c1a7254fdf
4 changed files with 51 additions and 30 deletions

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> {
use std::io::Write;
let bytes = base64_decode(&data_base64)?;
let p = std::path::Path::new(&path);
let tmp = p.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())
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)
}
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {

View file

@ -931,18 +931,16 @@ async function initUI(): 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 });
toast(t("saved"));
const content = isSum ? editor.getText() : editor.getSheetWithResults();
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);
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
if (await writeImageFile(b64)) {
toast(t("saved"));
}
} catch {