diff --git a/crates/skilld-command/src/remote.rs b/crates/skilld-command/src/remote.rs index a9c71785..00865d99 100644 --- a/crates/skilld-command/src/remote.rs +++ b/crates/skilld-command/src/remote.rs @@ -1098,7 +1098,7 @@ impl SkilldRemote { body: vec![], response_limit: limit, }; - self.execute(request, AllowedOrigin::Service(self.endpoint.clone())) + self.execute(request, AllowedOrigin::Artifact(self.endpoint.clone())) .map(|response| response.body) } @@ -2371,9 +2371,29 @@ fn invalid_update_response() -> RemoteError { #[derive(Clone)] enum AllowedOrigin { Service(Url), + /// Artifact bytes come from the service origin or from one of its + /// subdomains, such as `artifacts.skilld.dev` for `skilld.dev`. + Artifact(Url), Github, } +fn same_origin(url: &Url, base: &Url) -> bool { + url.scheme() == base.scheme() + && url.host_str() == base.host_str() + && url.port_or_known_default() == base.port_or_known_default() +} + +fn is_https_subdomain_of(url: &Url, base: &Url) -> bool { + let (Some(host), Some(base_host)) = (url.host_str(), base.host_str()) else { + return false; + }; + url.scheme() == "https" + && url.port().is_none() + && host + .strip_suffix(base_host) + .is_some_and(|prefix| prefix.len() > 1 && prefix.ends_with('.')) +} + fn validate_request_url(value: &str, allowed: &AllowedOrigin) -> Result<(), RemoteError> { let url = Url::parse(value) .map_err(|_| RemoteError::new("INVALID_REMOTE_URL", "a remote URL is invalid"))?; @@ -2388,11 +2408,8 @@ fn validate_url(url: &Url, allowed: &AllowedOrigin) -> Result<(), RemoteError> { )); } let allowed = match allowed { - AllowedOrigin::Service(base) => { - url.scheme() == base.scheme() - && url.host_str() == base.host_str() - && url.port_or_known_default() == base.port_or_known_default() - } + AllowedOrigin::Service(base) => same_origin(url, base), + AllowedOrigin::Artifact(base) => same_origin(url, base) || is_https_subdomain_of(url, base), AllowedOrigin::Github => { url.scheme() == "https" && url.host_str() == Some("api.github.com") diff --git a/crates/skilld-command/tests/remote.rs b/crates/skilld-command/tests/remote.rs index 6df761e1..ed8c148a 100644 --- a/crates/skilld-command/tests/remote.rs +++ b/crates/skilld-command/tests/remote.rs @@ -1745,6 +1745,60 @@ fn a_verified_remote_install_uses_resolution_root_grant_and_content_in_order() { assert!(requests[3].url.ends_with("/content")); } +#[test] +fn a_public_grant_may_serve_content_from_a_service_subdomain() { + let (pin, mut responses) = verified_remote_responses(); + let mut grant: serde_json::Value = serde_json::from_slice(&responses[2].body).unwrap(); + grant["contentUrl"] = json!("https://artifacts.skilld.dev/sha256/example"); + responses[2] = response(200, serde_json::to_vec(&grant).unwrap()); + let http = Arc::new(FakeHttp::with(responses)); + let remote = SkilldRemote::new( + http.clone(), + Arc::new(NoTokenProvider), + NativeRemoteConfig::Pinned(pin), + ) + .with_endpoint("https://skilld.dev") + .unwrap() + .with_sleeper(Arc::new(NoSleep)); + let selector = RemoteSelector::parse("skilld:skilld-dev/skills/example").unwrap(); + + let prepared = remote.prepare(&selector, false).unwrap(); + + assert!(matches!( + prepared.source_status, + SourceStatus::Verified { .. } + )); + let requests = http.requests.lock().unwrap(); + assert_eq!(requests.len(), 4); + assert_eq!( + requests[3].url, + "https://artifacts.skilld.dev/sha256/example" + ); +} + +#[test] +fn a_public_grant_on_an_unrelated_origin_is_rejected_before_download() { + let (pin, mut responses) = verified_remote_responses(); + let mut grant: serde_json::Value = serde_json::from_slice(&responses[2].body).unwrap(); + grant["contentUrl"] = json!("https://example.com/sha256/example"); + responses[2] = response(200, serde_json::to_vec(&grant).unwrap()); + let http = Arc::new(FakeHttp::with(responses)); + let remote = SkilldRemote::new( + http.clone(), + Arc::new(NoTokenProvider), + NativeRemoteConfig::Pinned(pin), + ) + .with_endpoint("https://skilld.dev") + .unwrap() + .with_sleeper(Arc::new(NoSleep)); + let selector = RemoteSelector::parse("skilld:skilld-dev/skills/example").unwrap(); + + let error = remote.prepare(&selector, false).unwrap_err(); + + assert_eq!(error.code, "REMOTE_ORIGIN_REJECTED"); + assert_eq!(http.requests.lock().unwrap().len(), 3); +} + #[test] fn a_private_artifact_download_sends_the_account_and_one_time_grant() { let (pin, mut responses) = verified_remote_responses();