From 76b7c588deb1db605e6ddf971a36e374efc9776d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 6 Aug 2026 10:00:50 +0200 Subject: [PATCH] Added path validation to the share file endpoint --- .../Tools/Services/PluginShareService.cs | 11 ++++++ runtime/src/share_sheet.rs | 38 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs index abc6d1e7..a56c1886 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs @@ -11,9 +11,20 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService)); + /// + /// Keep in sync with SHARE_FILE_EXTENSION in runtime/src/share_sheet.rs: the runtime only hands + /// archives with this extension to the native share sheet. + /// public const string PLUGIN_FILE_EXTENSION = ".mwplugin"; + private const string PLUGIN_FILE_NAME = "plugin.lua"; + + /// + /// Keep in sync with SHARE_DIRECTORY_NAME in runtime/src/share_sheet.rs: the runtime only hands + /// archives from a directory with this name to the native share sheet. + /// private const string TEMPORARY_ARCHIVE_DIRECTORY = "mindwork-ai-studio-plugin-shares"; + private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24; private const int FILE_NAME_PREFIX_MAX_LEN = 80; diff --git a/runtime/src/share_sheet.rs b/runtime/src/share_sheet.rs index 7c0cf161..a0ffacd7 100644 --- a/runtime/src/share_sheet.rs +++ b/runtime/src/share_sheet.rs @@ -1,9 +1,17 @@ use axum::Json; use log::{error, info}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::api_token::APIToken; +/// The directory the app creates its shareable plugin archives in. Keep in sync with +/// PluginShareService.TEMPORARY_ARCHIVE_DIRECTORY on the .NET side. +const SHARE_DIRECTORY_NAME: &str = "mindwork-ai-studio-plugin-shares"; + +/// The file extension of plugin archives, without the leading dot. Keep in sync with +/// PluginShareService.PLUGIN_FILE_EXTENSION on the .NET side. +const SHARE_FILE_EXTENSION: &str = "mwplugin"; + #[derive(Deserialize)] pub struct ShareFileRequest { file_path: String, @@ -25,6 +33,18 @@ pub async fn share_file(_token: APIToken, Json(request): Json) return failure(format!("The requested path is not an existing file: {}", path.to_string_lossy())); } + // Resolve the path before validating it, so a symlink with a matching name cannot point at an + // arbitrary file. We share the original path afterwards, though: on Windows, canonicalize + // returns a \\?\ path, which the WinRT storage APIs do not accept. + let resolved_path = match std::fs::canonicalize(&path) { + Ok(resolved_path) => resolved_path, + Err(error) => return failure(format!("The requested path could not be resolved: {error}")), + }; + + if !is_shareable_archive(&resolved_path) { + return failure(format!("The requested path is not a plugin archive created by AI Studio: {}", path.to_string_lossy())); + } + let result = share_file_on_platform(path).await; match result { Ok(()) => { @@ -42,6 +62,22 @@ pub async fn share_file(_token: APIToken, Json(request): Json) } } +/// Checks that a path points to a plugin archive the app itself created for sharing. This keeps the +/// endpoint from handing arbitrary readable files to the operating system's share UI. +/// +/// We match the directory by name instead of comparing it against the temporary directory: Rust and +/// .NET do not have to agree on where that is, and a mismatch would break sharing entirely. +fn is_shareable_archive(path: &Path) -> bool { + let has_archive_extension = path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case(SHARE_FILE_EXTENSION)); + if !has_archive_extension { + return false; + } + + path.parent() + .and_then(|parent| parent.file_name()) + .is_some_and(|directory_name| directory_name == SHARE_DIRECTORY_NAME) +} + fn failure(issue: impl Into) -> Json { Json(ShareFileResponse { success: false,