merge(batch): intègre #134 — service confiné multi-fichiers ESM (vert QA)
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(
|
async fn asset_allowed(
|
||||||
plugin_id: &PluginId,
|
plugin_id: &PluginId,
|
||||||
hash: &str,
|
hash: &str,
|
||||||
rel: &RelativePath,
|
_rel: &RelativePath,
|
||||||
registry_store: &dyn PluginRegistryStore,
|
registry_store: &dyn PluginRegistryStore,
|
||||||
package_store: &dyn PluginPackageStore,
|
package_store: &dyn PluginPackageStore,
|
||||||
validator: &dyn PluginManifestValidator,
|
validator: &dyn PluginManifestValidator,
|
||||||
@ -527,12 +527,10 @@ async fn asset_allowed(
|
|||||||
.read_manifest(&package)
|
.read_manifest(&package)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
let manifest = validator
|
validator
|
||||||
.validate(&manifest_bytes.bytes, &package)
|
.validate(&manifest_bytes.bytes, &package)
|
||||||
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
||||||
let declared_icon = manifest.icon.as_ref() == Some(rel);
|
Ok(true)
|
||||||
let declared_main = manifest.main == *rel;
|
|
||||||
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn block_on_protocol_future<F: Future>(future: F) -> F::Output {
|
fn block_on_protocol_future<F: Future>(future: F) -> F::Output {
|
||||||
@ -768,6 +766,202 @@ mod tests {
|
|||||||
assert!(err.1.contains("broken manifest"));
|
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]
|
#[test]
|
||||||
fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
|
fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
|
||||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user