feat(plugins): sert tout fichier confiné du package installé (multi-fichiers ESM) (#134)
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 <noreply@anthropic.com>
This commit is contained in:
@ -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<F: 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();
|
||||
|
||||
Reference in New Issue
Block a user