From cd54eb8c62f0092914222b5609f3d2cc8429f4c6 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 2 Aug 2026 15:52:48 +0200 Subject: [PATCH 1/2] feat(plugins): support direct download_url in registry entries --- .../crates/app/src/commands/plugin_store.rs | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/app/src/commands/plugin_store.rs b/src-tauri/crates/app/src/commands/plugin_store.rs index 17bd9b0a..0715f98d 100644 --- a/src-tauri/crates/app/src/commands/plugin_store.rs +++ b/src-tauri/crates/app/src/commands/plugin_store.rs @@ -107,6 +107,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,15 +487,31 @@ 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. Require https — it can't be a downgrade / loopback target — and the + // bytes are still blake3-verified against the registry pin below either way. + let url = match &entry.download_url { + Some(direct) => { + if !direct.starts_with("https://") { + return Err(AppError::Other(format!( + "plugin {plugin_id}: registry download_url must be https" + ))); + } + 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 + ) + } + }; let client = reqwest::Client::builder() .timeout(Duration::from_secs(60)) From 977635bdec80d4f1b6ea1d10482c0e71a3b59447 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 2 Aug 2026 16:15:57 +0200 Subject: [PATCH 2/2] fix(plugins): validate download_url + install redirects with the SSRF guard --- .../crates/app/src/commands/plugin_store.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/app/src/commands/plugin_store.rs b/src-tauri/crates/app/src/commands/plugin_store.rs index 0715f98d..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; @@ -489,13 +490,16 @@ pub async fn install_plugin_from_registry( // 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. Require https — it can't be a downgrade / loopback target — and the - // bytes are still blake3-verified against the registry pin below either way. + // 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 !direct.starts_with("https://") { + if !is_safe_motion_url(direct) { return Err(AppError::Other(format!( - "plugin {plugin_id}: registry download_url must be https" + "plugin {plugin_id}: registry download_url is not a safe https URL" ))); } direct.clone() @@ -513,8 +517,21 @@ pub async fn install_plugin_from_registry( } }; + // 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