From 71d08795d963a6d9f7c072a235116a364c138af8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 23:47:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20sert=20tout=20fichier=20confin?= =?UTF-8?q?=C3=A9=20du=20package=20install=C3=A9=20(multi-fichiers=20ESM)?= =?UTF-8?q?=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente §22.1 : asset_allowed autorise tout chemin relatif dès lors que les gardes déjà en place sont satisfaites (entrée registre active, content_hash du package, confinement canonicalize en aval), au lieu de restreindre au triplet main/icon/assets. Débloque les imports ESM multi-fichiers (./constants.js, ./core/x.js) sans affaiblir le modèle de menace fixé à l'install (#135). Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/plugins.rs | 204 +++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 4352f2a..096194c 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -504,7 +504,7 @@ fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder async fn asset_allowed( plugin_id: &PluginId, hash: &str, - rel: &RelativePath, + _rel: &RelativePath, registry_store: &dyn PluginRegistryStore, package_store: &dyn PluginPackageStore, validator: &dyn PluginManifestValidator, @@ -527,12 +527,10 @@ async fn asset_allowed( .read_manifest(&package) .await .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; - let manifest = validator + validator .validate(&manifest_bytes.bytes, &package) .map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?; - let declared_icon = manifest.icon.as_ref() == Some(rel); - let declared_main = manifest.main == *rel; - Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/")) + Ok(true) } fn block_on_protocol_future(future: F) -> F::Output { @@ -768,6 +766,202 @@ mod tests { assert!(err.1.contains("broken manifest")); } + #[test] + fn plugin_asset_response_serves_confined_file_not_declared_in_manifest() { + let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap(); + let hash = ContentHash::new("abc123").unwrap(); + let registry = FakeRegistry { + registry: Mutex::new(PluginRegistry { + version: 1, + plugins: vec![PluginRegistryEntry { + id: plugin_id.clone(), + lifecycle_state: PluginLifecycleState::Enabled, + source: PluginInstallSource::Directory { + path_label: "/source/plugin".to_owned(), + }, + content_hash: hash.clone(), + restart_required: false, + error: None, + }], + }), + }; + let packages = FakePackages { + manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(), + }; + let validator = AcceptingValidator { + plugin_id: plugin_id.clone(), + main: RelativePath::new("dist/index.js").unwrap(), + }; + let app_data = test_app_data_dir("plugin-asset-undeclared"); + let rel = RelativePath::new("dist/constants.js").unwrap(); + let target = app_data + .join("plugins") + .join("installed") + .join(plugin_id.as_str()) + .join(rel.as_str()); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "export const answer = 42;").unwrap(); + let request = http::Request::builder() + .uri(format!( + "idea-plugin://{}/current/{}/{}", + plugin_id.as_str(), + hash.as_str(), + rel.as_str() + )) + .body(Vec::new()) + .unwrap(); + + let response = + plugin_asset_response_with_stores(&app_data, request, ®istry, &packages, &validator) + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body(), b"export const answer = 42;"); + + std::fs::remove_dir_all(app_data).ok(); + } + + #[test] + fn plugin_asset_response_rejects_invalid_hash_or_inactive_plugin() { + let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap(); + let hash = ContentHash::new("abc123").unwrap(); + let rel = RelativePath::new("dist/constants.js").unwrap(); + let registry = FakeRegistry { + registry: Mutex::new(PluginRegistry { + version: 1, + plugins: vec![PluginRegistryEntry { + id: plugin_id.clone(), + lifecycle_state: PluginLifecycleState::Enabled, + source: PluginInstallSource::Directory { + path_label: "/source/plugin".to_owned(), + }, + content_hash: hash.clone(), + restart_required: false, + error: None, + }], + }), + }; + let packages = FakePackages { + manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(), + }; + let validator = AcceptingValidator { + plugin_id: plugin_id.clone(), + main: RelativePath::new("dist/index.js").unwrap(), + }; + let app_data = test_app_data_dir("plugin-asset-rejected"); + let target = app_data + .join("plugins") + .join("installed") + .join(plugin_id.as_str()) + .join(rel.as_str()); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "export const answer = 42;").unwrap(); + let invalid_hash_request = http::Request::builder() + .uri(format!( + "idea-plugin://{}/current/deadbeef/{}", + plugin_id.as_str(), + rel.as_str() + )) + .body(Vec::new()) + .unwrap(); + + let invalid_hash_err = plugin_asset_response_with_stores( + &app_data, + invalid_hash_request, + ®istry, + &packages, + &validator, + ) + .unwrap_err(); + + assert_eq!(invalid_hash_err.0, StatusCode::FORBIDDEN); + + registry.registry.lock().unwrap().plugins[0].lifecycle_state = + PluginLifecycleState::Disabled; + let inactive_request = http::Request::builder() + .uri(format!( + "idea-plugin://{}/current/{}/{}", + plugin_id.as_str(), + hash.as_str(), + rel.as_str() + )) + .body(Vec::new()) + .unwrap(); + + let inactive_err = plugin_asset_response_with_stores( + &app_data, + inactive_request, + ®istry, + &packages, + &validator, + ) + .unwrap_err(); + + assert_eq!(inactive_err.0, StatusCode::FORBIDDEN); + + std::fs::remove_dir_all(app_data).ok(); + } + + #[test] + fn plugin_asset_response_rejects_symlink_path_traversal() { + let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap(); + let hash = ContentHash::new("abc123").unwrap(); + let rel = RelativePath::new("assets/leak.txt").unwrap(); + let registry = FakeRegistry { + registry: Mutex::new(PluginRegistry { + version: 1, + plugins: vec![PluginRegistryEntry { + id: plugin_id.clone(), + lifecycle_state: PluginLifecycleState::Enabled, + source: PluginInstallSource::Directory { + path_label: "/source/plugin".to_owned(), + }, + content_hash: hash.clone(), + restart_required: false, + error: None, + }], + }), + }; + let packages = FakePackages { + manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(), + }; + let validator = AcceptingValidator { + plugin_id: plugin_id.clone(), + main: RelativePath::new("dist/index.js").unwrap(), + }; + let app_data = test_app_data_dir("plugin-asset-traversal"); + let plugin_root = app_data + .join("plugins") + .join("installed") + .join(plugin_id.as_str()); + let assets = plugin_root.join("assets"); + std::fs::create_dir_all(&assets).unwrap(); + let outside = app_data.join("outside.txt"); + std::fs::write(&outside, "secret").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, assets.join("leak.txt")).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(&outside, assets.join("leak.txt")).unwrap(); + let request = http::Request::builder() + .uri(format!( + "idea-plugin://{}/current/{}/{}", + plugin_id.as_str(), + hash.as_str(), + rel.as_str() + )) + .body(Vec::new()) + .unwrap(); + + let err = + plugin_asset_response_with_stores(&app_data, request, ®istry, &packages, &validator) + .unwrap_err(); + + assert_eq!(err.0, StatusCode::FORBIDDEN); + assert!(err.1.contains("escapes plugin root")); + + std::fs::remove_dir_all(app_data).ok(); + } + #[test] fn plugin_asset_response_includes_cors_headers_for_dynamic_import() { let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();