Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions src-tauri/crates/app/src/commands/plugin_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +108,15 @@ struct RegistryEntry {
blake3: String,
#[serde(default)]
asset: Option<String>,
/// 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<String>,
permissions: RegistryPermissions,
#[serde(default)]
tags: Vec<String>,
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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
Expand Down