diff --git a/src-tauri/crates/app/src/commands/plugin_store.rs b/src-tauri/crates/app/src/commands/plugin_store.rs index 17bd9b0a..6b796023 100644 --- a/src-tauri/crates/app/src/commands/plugin_store.rs +++ b/src-tauri/crates/app/src/commands/plugin_store.rs @@ -21,6 +21,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use tauri::State; +use waveflow_core::artwork::motion_cache::is_safe_motion_url; use waveflow_core::plugin::is_bundled_plugin; use waveflow_core::plugin::manifest::{LocalizedString, Manifest}; use waveflow_core::plugin::PluginPaths; @@ -107,6 +108,15 @@ struct RegistryEntry { blake3: String, #[serde(default)] asset: Option, + /// Optional direct download URL for the release asset. When present it + /// OVERRIDES the GitHub `releases/download` URL built from `repo` — this + /// lets the app-controlled registry endpoint host a plugin's binary itself + /// (e.g. on `waveflow.app`) instead of a public GitHub release, so a + /// closed-source plugin can be distributed binary-only with no public repo. + /// Trusted like the rest of the entry (the registry is the trust anchor) + /// and still blake3-verified after download; required to be `https`. + #[serde(default)] + download_url: Option, permissions: RegistryPermissions, #[serde(default)] tags: Vec, @@ -478,18 +488,50 @@ pub async fn install_plugin_from_registry( ))); } - // The release asset lives in the entry's own repo (releases/download). - let asset = entry - .asset - .clone() - .unwrap_or_else(|| format!("{}-v{}.zip", entry.id, entry.version)); - let url = format!( - "https://github.com/{}/releases/download/v{}/{}", - entry.repo, entry.version, asset - ); + // A registry-provided `download_url` overrides the GitHub `releases/download` + // URL (see the field doc) so the app-controlled endpoint can host the binary + // itself. The bytes are blake3-verified against the registry pin below, but + // the URL is a fetch the app makes, so validate it with the shared SSRF + // guard: https + reject localhost / loopback / private / link-local hosts + // (and userinfo forms pointing at them). Even a compromised entry must not + // be able to make the app hit an internal address. + let url = match &entry.download_url { + Some(direct) => { + if !is_safe_motion_url(direct) { + return Err(AppError::Other(format!( + "plugin {plugin_id}: registry download_url is not a safe https URL" + ))); + } + direct.clone() + } + None => { + // The release asset lives in the entry's own repo (releases/download). + let asset = entry + .asset + .clone() + .unwrap_or_else(|| format!("{}-v{}.zip", entry.id, entry.version)); + format!( + "https://github.com/{}/releases/download/v{}/{}", + entry.repo, entry.version, asset + ) + } + }; + // Follow redirects (a GitHub release URL 302s to a CDN) but re-validate + // EVERY hop with the same SSRF guard — the initial URL was checked above, + // and neither it nor a redirect may point the download at an internal + // target. Legit public CDNs pass; a redirect to an internal host is refused. let client = reqwest::Client::builder() .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else if is_safe_motion_url(attempt.url().as_str()) { + attempt.follow() + } else { + attempt.error("unsafe redirect target") + } + })) .build() .map_err(|e| AppError::Other(format!("http client: {e}")))?; let mut resp = client