fix: restrict external file access

This commit is contained in:
Yuri Karamian
2026-08-17 21:02:05 +02:00
parent 81e4e26290
commit 33d77cf2fd
8 changed files with 264 additions and 65 deletions
-1
View File
@@ -34,7 +34,6 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
-10
View File
@@ -17,9 +17,6 @@ importers:
'@tauri-apps/plugin-fs':
specifier: ^2.4.5
version: 2.4.5
'@tauri-apps/plugin-opener':
specifier: ^2.5.3
version: 2.5.3
'@tauri-apps/plugin-updater':
specifier: ^2.10.0
version: 2.10.0
@@ -741,9 +738,6 @@ packages:
'@tauri-apps/plugin-fs@2.4.5':
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-updater@2.10.0':
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==}
@@ -2178,10 +2172,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-opener@2.5.3':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.0':
dependencies:
'@tauri-apps/api': 2.10.1
+1 -4
View File
@@ -24,9 +24,6 @@
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:default",
"fs:allow-read",
"fs:allow-read-file",
"opener:allow-reveal-item-in-dir"
"fs:allow-read-file"
]
}
+43
View File
@@ -21,6 +21,25 @@ pub fn allow_vault_assets<R: Runtime>(app: &AppHandle<R>, vault_path: &Path) ->
.map_err(|error| error.to_string())
}
/// Grants access to relative assets next to a Markdown file explicitly opened by the user.
pub fn allow_external_note_assets<R: Runtime>(
app: &AppHandle<R>,
note_path: &Path,
) -> Result<(), String> {
let note = std::fs::canonicalize(note_path).map_err(|error| {
format!(
"Failed to resolve external note path '{}': {error}",
note_path.display()
)
})?;
let parent = note
.parent()
.ok_or_else(|| "External note has no parent directory".to_string())?;
app.asset_protocol_scope()
.allow_directory(parent, true)
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -65,4 +84,28 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn external_note_scope_allows_relative_assets_only_below_its_directory() {
let root = test_directory("external-note-assets");
let note_dir = root.join("shared note");
let assets = note_dir.join("images");
fs::create_dir_all(&assets).unwrap();
let note = note_dir.join("note.md");
let image = assets.join("image.png");
let outside = root.join("outside.png");
fs::write(&note, b"note").unwrap();
fs::write(&image, b"image").unwrap();
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_external_note_assets(app.handle(), &note).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note));
assert!(scope.is_allowed(&image));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
}
+186 -47
View File
@@ -3,8 +3,9 @@ use crate::search::SearchIndex;
use crate::state::AppState;
use crate::types::*;
use crate::vault::{operations, watcher};
use std::path::Path;
use tauri::{AppHandle, Manager, State};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Runtime, State};
use tauri_plugin_fs::FsExt;
fn index_note_bg(state: &State<'_, AppState>, path: &str) {
let search = state.search_index.lock().ok().and_then(|g| g.clone());
@@ -415,6 +416,7 @@ mod custom_theme_reference_tests {
#[tauri::command]
pub fn export_custom_theme(
app: AppHandle,
state: State<'_, AppState>,
id: String,
path: String,
@@ -427,15 +429,18 @@ pub fn export_custom_theme(
.ok_or_else(|| "Theme not found".to_string())?;
let export = serde_json::json!({ "version": 1, "themes": [theme] });
let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?;
ensure_scoped_path(&app, Path::new(&path))?;
std::fs::write(&path, data).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn import_custom_themes(
app: AppHandle,
state: State<'_, AppState>,
path: String,
) -> Result<Vec<crate::types::CustomTheme>, String> {
ensure_scoped_path(&app, Path::new(&path))?;
let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
let themes: Vec<crate::types::CustomTheme> =
@@ -1468,7 +1473,8 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
/// Copy an image file to the system clipboard.
#[cfg(desktop)]
#[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
pub fn copy_image_to_clipboard(app: AppHandle, path: String) -> Result<(), String> {
ensure_readable_path(&app, Path::new(&path))?;
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
let img =
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
@@ -1489,7 +1495,7 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
#[cfg(mobile)]
#[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
pub fn copy_image_to_clipboard(_app: AppHandle, _path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
@@ -1914,70 +1920,203 @@ fn percent_decode(s: &str) -> String {
result
}
// ── Open file/URL with system default handler ──
// ── Open files and URLs with the system default handler ──
fn xdg_open(arg: &str) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
let mut cmd = std::process::Command::new("xdg-open");
cmd.arg(arg);
// Clear AppImage environment so child processes find host binaries
// (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
fn active_vault_path<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
let state = app.state::<AppState>();
let config = state.config.lock().map_err(|error| error.to_string())?;
config
.active_vault
.as_deref()
.map(PathBuf::from)
.ok_or_else(|| "No active vault".to_string())
}
fn path_is_in_active_vault<R: Runtime>(app: &AppHandle<R>, path: &Path) -> bool {
let Ok(vault) = active_vault_path(app)
.and_then(|path| std::fs::canonicalize(path).map_err(|error| error.to_string()))
else {
return false;
};
let canonical = if path.exists() {
std::fs::canonicalize(path).ok()
} else {
path.parent()
.and_then(|parent| std::fs::canonicalize(parent).ok())
};
canonical.is_some_and(|path| path.starts_with(vault))
}
fn ensure_scoped_path<R: Runtime>(app: &AppHandle<R>, path: &Path) -> Result<(), String> {
if app.fs_scope().is_allowed(path) {
Ok(())
} else {
Err("Path was not selected by the user".to_string())
}
}
fn ensure_readable_path<R: Runtime>(app: &AppHandle<R>, path: &Path) -> Result<(), String> {
if path.is_file() && (path_is_in_active_vault(app, path) || app.fs_scope().is_allowed(path)) {
Ok(())
} else {
Err("File must be inside the active vault or selected by the user".to_string())
}
}
fn ensure_writable_path<R: Runtime>(app: &AppHandle<R>, path: &Path) -> Result<(), String> {
if path_is_in_active_vault(app, path) || app.fs_scope().is_allowed(path) {
Ok(())
} else {
Err("Destination must be inside the active vault or selected by the user".to_string())
}
}
fn validate_external_url(url: &str) -> Result<(), String> {
if url.chars().any(char::is_whitespace) {
return Err("URL must not contain whitespace".to_string());
}
let parsed = reqwest::Url::parse(url).map_err(|_| "Invalid URL".to_string())?;
match parsed.scheme() {
"http" | "https" | "mailto" | "tel" | "sms" => Ok(()),
_ => Err("Unsupported URL scheme".to_string()),
}
}
#[cfg(target_os = "linux")]
fn open_linux(argument: &std::ffi::OsStr) -> Result<(), String> {
let mut command = std::process::Command::new("xdg-open");
command.arg(argument);
if std::env::var("APPIMAGE").is_ok() {
command
.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
command.env("PATH", original_path);
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(arg)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
command
.spawn()
.map(|_| ())
.map_err(|error| format!("Failed to open item: {error}"))
}
fn open_path_with_system(path: &Path) -> Result<(), String> {
#[cfg(target_os = "linux")]
return open_linux(path.as_os_str());
#[cfg(not(target_os = "linux"))]
tauri_plugin_opener::open_path(path, None::<&str>).map_err(|error| error.to_string())
}
fn open_url_with_system(url: &str) -> Result<(), String> {
#[cfg(target_os = "linux")]
return open_linux(std::ffi::OsStr::new(url));
#[cfg(not(target_os = "linux"))]
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|error| error.to_string())
}
#[cfg(test)]
mod external_access_tests {
use super::{ensure_readable_path, ensure_writable_path, validate_external_url};
use crate::state::AppState;
use crate::types::AppConfig;
use std::fs;
use tauri_plugin_fs::FsExt;
#[test]
fn external_urls_allow_supported_schemes() {
for url in [
"https://helixnotes.com",
"http://example.com",
"mailto:hello@example.com",
"tel:+123456789",
"sms:+123456789",
] {
assert!(validate_external_url(url).is_ok(), "{url}");
}
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: open the URL/path without flashing a console window.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
std::process::Command::new("cmd")
.args(["/C", "start", "", arg])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
#[test]
fn external_urls_reject_commands_and_unsupported_schemes() {
for url in [
"https://example.com & calc.exe",
"file:///etc/passwd",
"javascript:alert(1)",
"not a URL",
] {
assert!(validate_external_url(url).is_err(), "{url}");
}
}
#[cfg(mobile)]
{
let _ = arg;
#[test]
fn file_access_requires_the_active_vault_or_an_explicit_user_scope() {
let root = std::env::temp_dir().join(format!(
"helixnotes-external-access-{}",
uuid::Uuid::new_v4()
));
let vault = root.join("vault");
let vault_file = vault.join("note.md");
let outside_file = root.join("outside.txt");
let outside_destination = root.join("export.txt");
fs::create_dir_all(&vault).unwrap();
fs::write(&vault_file, b"note").unwrap();
fs::write(&outside_file, b"outside").unwrap();
let config = AppConfig {
active_vault: Some(vault.to_string_lossy().into_owned()),
..Default::default()
};
let app = tauri::test::mock_builder()
.plugin(tauri_plugin_fs::init())
.manage(AppState::new(config))
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.unwrap();
assert!(ensure_readable_path(app.handle(), &vault_file).is_ok());
assert!(ensure_readable_path(app.handle(), &outside_file).is_err());
assert!(ensure_writable_path(app.handle(), &outside_destination).is_err());
app.fs_scope().allow_file(&outside_file).unwrap();
app.fs_scope().allow_file(&outside_destination).unwrap();
assert!(ensure_readable_path(app.handle(), &outside_file).is_ok());
assert!(ensure_writable_path(app.handle(), &outside_destination).is_ok());
fs::remove_dir_all(root).unwrap();
}
Ok(())
}
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
pub fn open_file(app: AppHandle, path: String) -> Result<(), String> {
ensure_readable_path(&app, Path::new(&path))?;
open_path_with_system(Path::new(&path))
}
#[tauri::command]
pub fn reveal_file(app: AppHandle, path: String) -> Result<(), String> {
ensure_readable_path(&app, Path::new(&path))?;
tauri_plugin_opener::reveal_item_in_dir(path).map_err(|error| error.to_string())
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
validate_external_url(&url)?;
open_url_with_system(&url)
}
#[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
pub fn copy_file_to(app: AppHandle, source: String, destination: String) -> Result<(), String> {
ensure_readable_path(&app, Path::new(&source))?;
ensure_writable_path(&app, Path::new(&destination))?;
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String> {
pub fn write_bytes_to(app: AppHandle, destination: String, data: Vec<u8>) -> Result<(), String> {
ensure_writable_path(&app, Path::new(&destination))?;
std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(())
}
+27
View File
@@ -12,6 +12,7 @@ mod vault;
use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{
@@ -57,6 +58,16 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.on_webview_event(|webview, event| {
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
let scope = webview.fs_scope();
for path in paths {
if path.is_file() {
let _ = scope.allow_file(path);
}
}
}
})
.on_page_load(|webview, payload| {
#[cfg(target_os = "macos")]
if webview.label() == "main"
@@ -127,6 +138,14 @@ pub fn run() {
std::env::current_dir().unwrap_or_default().join(&path)
};
if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ =
asset_scope::allow_external_note_assets(app.handle(), &resolved);
}
let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
@@ -205,6 +224,7 @@ pub fn run() {
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::reveal_file,
commands::open_url,
commands::copy_file_to,
commands::write_bytes_to,
@@ -324,6 +344,13 @@ pub fn run() {
std::path::Path::new(&cwd).join(path)
};
if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ = asset_scope::allow_external_note_assets(app, &resolved);
}
let _ = app.emit("open-file", resolved_str.to_string());
}
}
+4
View File
@@ -364,6 +364,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path });
}
export async function revealFile(path: string): Promise<void> {
return invoke("reveal_file", { path });
}
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
+3 -3
View File
@@ -37,12 +37,12 @@
reorderQuickAccess,
moveNote,
getAllTags,
createDailyNote
createDailyNote,
revealFile
} from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
@@ -1567,7 +1567,7 @@
Open in New Window
</button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>