From 7377527f7c6fd61657a2d29141a9d55de2f1af28 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 00:02:24 +0900 Subject: [PATCH] plugin: implement https host api --- Cargo.lock | 2 + crates/manifest/src/plugin.rs | 37 +- crates/pod/Cargo.toml | 1 + crates/pod/src/feature/plugin.rs | 961 +++++++++++++++++++++++++++++-- crates/yoi/src/plugin_cli.rs | 24 + package.nix | 2 +- 6 files changed, 987 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b77cd3c..65bb4de8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2353,6 +2353,7 @@ dependencies = [ "pod-store", "protocol", "provider", + "reqwest", "schemars", "serde", "serde_json", @@ -2807,6 +2808,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 70af3127..cd8ee17e 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -74,11 +74,13 @@ pub struct PluginGrantConfig { pub digest: Option, /// Explicit capabilities granted for the pinned package identity/version/digest. pub permissions: Vec, + /// Bounded outbound HTTPS allowlist entries for `host_api.https`. + pub https: Vec, } impl PluginGrantConfig { pub fn is_empty(&self) -> bool { - self.permissions.is_empty() + self.permissions.is_empty() && self.https.is_empty() } pub fn binding_error( @@ -87,7 +89,7 @@ impl PluginGrantConfig { digest: &str, version: &str, ) -> Option<&'static str> { - if self.permissions.is_empty() { + if self.is_empty() { return None; } let Some(grant_id) = &self.id else { @@ -128,6 +130,33 @@ pub enum PluginPermission { HostApi { api: PluginHostApi }, } +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PluginHttpsGrant { + /// Exact HTTPS request host allowed by this grant. Wildcards are intentionally unsupported. + pub host: String, + /// Uppercase HTTP methods allowed for this host, for example `GET` or `POST`. + pub methods: Vec, + /// Optional path prefixes allowed for this host. Empty means any absolute path on the host. + pub path_prefixes: Vec, +} + +impl PluginHttpsGrant { + pub fn label(&self) -> String { + let methods = if self.methods.is_empty() { + "".to_string() + } else { + self.methods.join(",") + }; + let paths = if self.path_prefixes.is_empty() { + "*".to_string() + } else { + self.path_prefixes.join(",") + }; + format!("{} {} {}", self.host, methods, paths) + } +} + impl PluginPermission { pub fn label(&self) -> String { match self { @@ -2052,6 +2081,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } }, version: Some(PluginExactVersion("0.1.0".to_string())), digest: Some(digest.clone()), permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + https: Vec::new(), }; let resolution = resolve_enabled_plugins( &PluginConfig { @@ -2077,18 +2107,21 @@ input_schema = { type = "object", properties = { query = { type = "string" } }, version: Some(PluginExactVersion("0.1.0".to_string())), digest: Some(digest.clone()), permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + https: Vec::new(), }, PluginGrantConfig { id: Some("project:example".to_string()), version: Some(PluginExactVersion("0.1.1".to_string())), digest: Some(digest.clone()), permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + https: Vec::new(), }, PluginGrantConfig { id: Some("project:example".to_string()), version: Some(PluginExactVersion("0.1.0".to_string())), digest: Some("sha256:unrelated".to_string()), permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + https: Vec::new(), }, ] { let resolution = resolve_enabled_plugins( diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index d890c684..d56bfc07 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -18,6 +18,7 @@ client = { workspace = true } pod-registry = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } toml = { workspace = true } diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index c8d58190..fd040ebb 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -2,11 +2,14 @@ //! //! This module registers *enabled* plugin package tool surface definitions and //! executes Tool calls through the minimal sandboxed `yoi-plugin-wasm-1` WASM -//! ABI. It deliberately does not grant filesystem, network, environment, hook, -//! service, ingress, or richer host API authority; those remain follow-up -//! boundaries. +//! ABI. It deliberately does not grant filesystem, environment, hook, service, +//! ingress, or ambient network authority. WASM Tools can only reach outbound HTTPS +//! through the explicit `yoi:https` host import plus matching permission and +//! allowlist grants. use std::collections::HashSet; +use std::io::Read as _; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; use std::sync::Arc; use std::time::Duration; @@ -18,7 +21,7 @@ use manifest::plugin::{ PluginConfig, PluginDiscoveryLimits, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_module, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use super::{ @@ -359,6 +362,443 @@ impl FeatureModule for PluginToolFeature { } } +impl PluginHttpsClient for ReqwestPluginHttpsClient { + fn execute( + &self, + request: &PluginHttpsRequest, + url: &reqwest::Url, + limits: PluginHttpsLimits, + ) -> Result { + validate_dns_target(url)?; + let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| { + PluginHttpsError::new(format!("unsupported HTTPS method `{}`", request.method)) + })?; + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(limits.timeout) + .no_proxy() + .user_agent("yoi-plugin-https-host-api/0.1") + .build() + .map_err(|error| { + PluginHttpsError::new(format!("HTTPS client build failed: {error}")) + })?; + let mut builder = client.request(method, url.clone()).timeout(limits.timeout); + for header in &request.headers { + let name = + reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { + PluginHttpsError::new(format!( + "invalid HTTPS request header name `{}`", + header.name + )) + })?; + let value = reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| { + PluginHttpsError::new(format!( + "invalid HTTPS request header value for `{}`", + header.name + )) + })?; + builder = builder.header(name, value); + } + if let Some(body) = &request.body { + builder = builder.body(body.clone()); + } + let mut response = builder.send().map_err(|error| { + if error.is_timeout() { + PluginHttpsError::new(format!("HTTPS request to {} timed out", safe_url(url))) + } else { + PluginHttpsError::new(format!( + "HTTPS request to {} failed: {error}", + safe_url(url) + )) + } + })?; + let status = response.status().as_u16(); + let headers = collect_https_response_headers(response.headers()); + let mut body = Vec::new(); + let read_limit = limits.max_response_bytes.saturating_add(1) as u64; + response + .by_ref() + .take(read_limit) + .read_to_end(&mut body) + .map_err(|error| { + PluginHttpsError::new(format!("HTTPS response read failed: {error}")) + })?; + let truncated = body.len() > limits.max_response_bytes; + if truncated { + body.truncate(limits.max_response_bytes); + } + Ok(PluginHttpsResponse { + status, + headers, + body: String::from_utf8_lossy(&body).into_owned(), + truncated, + }) + } +} + +fn execute_plugin_https_request( + record: &ResolvedPluginRecord, + client: &dyn PluginHttpsClient, + request_bytes: &[u8], +) -> Result, PluginHttpsError> { + if request_bytes.len() > PLUGIN_HTTPS_MAX_REQUEST_BYTES { + return Err(PluginHttpsError::new(format!( + "HTTPS request descriptor exceeds {} bytes", + PLUGIN_HTTPS_MAX_REQUEST_BYTES + ))); + } + authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { + PluginHttpsError::new(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + let request: PluginHttpsRequest = serde_json::from_slice(request_bytes) + .map_err(|error| PluginHttpsError::new(format!("invalid HTTPS request JSON: {error}")))?; + let url = validate_plugin_https_request(record, &request)?; + let mut response = client.execute(&request, &url, PluginHttpsLimits::default())?; + enforce_https_response_bounds(&mut response, PluginHttpsLimits::default()); + serde_json::to_vec(&response) + .map_err(|error| PluginHttpsError::new(format!("failed to encode HTTPS response: {error}"))) +} + +fn enforce_https_response_bounds(response: &mut PluginHttpsResponse, limits: PluginHttpsLimits) { + if response.body.len() > limits.max_response_bytes { + truncate_string_to_boundary(&mut response.body, limits.max_response_bytes); + response.truncated = true; + } + if response.headers.len() > PLUGIN_HTTPS_MAX_RESPONSE_HEADERS { + response.headers.truncate(PLUGIN_HTTPS_MAX_RESPONSE_HEADERS); + } + for header in &mut response.headers { + header.value = bounded_header_value(&header.value); + } +} + +fn truncate_string_to_boundary(value: &mut String, max_len: usize) { + if value.len() <= max_len { + return; + } + let mut boundary = max_len; + while boundary > 0 && !value.is_char_boundary(boundary) { + boundary -= 1; + } + value.truncate(boundary); +} + +fn validate_plugin_https_request( + record: &ResolvedPluginRecord, + request: &PluginHttpsRequest, +) -> Result { + let method = request.method.trim().to_ascii_uppercase(); + if method != request.method || !PLUGIN_HTTPS_ALLOWED_METHODS.contains(&method.as_str()) { + return Err(PluginHttpsError::new(format!( + "HTTPS method `{}` is not allowed", + request.method + ))); + } + if request.headers.len() > PLUGIN_HTTPS_MAX_REQUEST_HEADERS { + return Err(PluginHttpsError::new(format!( + "HTTPS request has too many headers (max {})", + PLUGIN_HTTPS_MAX_REQUEST_HEADERS + ))); + } + for header in &request.headers { + validate_https_header(header)?; + } + if let Some(body) = &request.body { + if body.len() > PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES { + return Err(PluginHttpsError::new(format!( + "HTTPS request body exceeds {} bytes", + PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES + ))); + } + } + let url = reqwest::Url::parse(&request.url) + .map_err(|error| PluginHttpsError::new(format!("invalid HTTPS URL: {error}")))?; + if url.scheme() != "https" { + return Err(PluginHttpsError::new(format!( + "unsupported URL scheme {:?}; only https is allowed", + url.scheme() + ))); + } + if url.host_str().is_none() { + return Err(PluginHttpsError::new("HTTPS URL must include a host")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(PluginHttpsError::new( + "HTTPS URLs with embedded credentials are not allowed", + )); + } + validate_static_https_target(&url)?; + authorize_https_allowlist(record, &method, &url)?; + Ok(url) +} + +fn validate_https_header(header: &PluginHttpsHeader) -> Result<(), PluginHttpsError> { + if header.name.is_empty() || header.name.len() > PLUGIN_HTTPS_MAX_HEADER_NAME_BYTES { + return Err(PluginHttpsError::new( + "HTTPS request header name is invalid", + )); + } + if header.value.len() > PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES { + return Err(PluginHttpsError::new(format!( + "HTTPS request header `{}` exceeds {} bytes", + header.name, PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES + ))); + } + if is_sensitive_header(&header.name) { + return Err(PluginHttpsError::new(format!( + "HTTPS request header `{}` is credential-like and must be supplied by an explicit future secret-ref grant, not guest memory", + header.name + ))); + } + reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { + PluginHttpsError::new(format!( + "invalid HTTPS request header name `{}`", + header.name + )) + })?; + reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| { + PluginHttpsError::new(format!( + "invalid HTTPS request header value for `{}`", + header.name + )) + })?; + Ok(()) +} + +fn authorize_https_allowlist( + record: &ResolvedPluginRecord, + method: &str, + url: &reqwest::Url, +) -> Result<(), PluginHttpsError> { + let host = canonical_host(url)?; + let path = url.path(); + let allowed = record.grants.https.iter().any(|grant| { + canonical_grant_host(&grant.host).as_deref() == Some(host.as_str()) + && grant + .methods + .iter() + .any(|allowed_method| allowed_method.eq_ignore_ascii_case(method)) + && (grant.path_prefixes.is_empty() + || grant + .path_prefixes + .iter() + .any(|prefix| !prefix.is_empty() && path.starts_with(prefix))) + }); + if allowed { + return Ok(()); + } + Err(PluginHttpsError::new(format!( + "HTTPS request {} {} is not covered by host/method/path grants", + method, + safe_url(url) + ))) +} + +fn canonical_grant_host(host: &str) -> Option { + let value = host.trim().trim_end_matches('.').to_ascii_lowercase(); + if value.is_empty() { None } else { Some(value) } +} + +fn has_usable_https_grant(record: &ResolvedPluginRecord) -> bool { + record.grants.https.iter().any(|grant| { + canonical_grant_host(&grant.host).is_some() + && grant.methods.iter().any(|method| { + let method = method.trim().to_ascii_uppercase(); + PLUGIN_HTTPS_ALLOWED_METHODS.contains(&method.as_str()) + }) + }) +} + +fn canonical_host(url: &reqwest::Url) -> Result { + url.host_str() + .map(|host| host.trim_end_matches('.').to_ascii_lowercase()) + .filter(|host| !host.is_empty()) + .ok_or_else(|| PluginHttpsError::new("HTTPS URL must include a host")) +} + +fn validate_static_https_target(url: &reqwest::Url) -> Result<(), PluginHttpsError> { + let host = canonical_host(url)?; + if is_forbidden_host_name(&host) { + return Err(PluginHttpsError::new(format!( + "HTTPS blocked local/private host {:?}", + host + ))); + } + if let Ok(ip) = host.parse::() { + validate_public_ip(ip, &host)?; + } + if url.cannot_be_a_base() { + return Err(PluginHttpsError::new( + "HTTPS URL target is not hierarchical", + )); + } + Ok(()) +} + +fn validate_dns_target(url: &reqwest::Url) -> Result<(), PluginHttpsError> { + let host = canonical_host(url)?; + if host.parse::().is_ok() { + return Ok(()); + } + let port = url + .port_or_known_default() + .ok_or_else(|| PluginHttpsError::new("HTTPS URL uses a scheme without a default port"))?; + let mut resolved = false; + for addr in (host.as_str(), port).to_socket_addrs().map_err(|error| { + PluginHttpsError::new(format!("DNS lookup failed for {:?}: {error}", host)) + })? { + resolved = true; + validate_public_ip(addr.ip(), &host)?; + } + if !resolved { + return Err(PluginHttpsError::new(format!( + "DNS lookup for {:?} returned no addresses", + host + ))); + } + Ok(()) +} + +fn validate_public_ip(ip: IpAddr, host: &str) -> Result<(), PluginHttpsError> { + let forbidden = match ip { + IpAddr::V4(ip) => is_forbidden_ipv4(ip), + IpAddr::V6(ip) => is_forbidden_ipv6(ip), + }; + if forbidden { + return Err(PluginHttpsError::new(format!( + "HTTPS blocked local/private address {ip} for host {:?}", + host + ))); + } + Ok(()) +} + +fn is_forbidden_host_name(host: &str) -> bool { + let lower = host.trim_end_matches('.').to_ascii_lowercase(); + lower == "localhost" || lower.ends_with(".localhost") +} + +fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_unspecified() + || ip.octets()[0] == 0 + || ip.octets()[0] >= 224 + || ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]) + || ip.octets()[0] == 169 && ip.octets()[1] == 254 + || ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0 + || ip.octets()[0] == 198 && (18..=19).contains(&ip.octets()[1]) +} + +fn is_forbidden_ipv6(ip: Ipv6Addr) -> bool { + ip.is_loopback() + || ip.is_unspecified() + || (ip.segments()[0] & 0xfe00) == 0xfc00 + || (ip.segments()[0] & 0xffc0) == 0xfe80 + || (ip.segments()[0] & 0xff00) == 0xff00 +} + +fn collect_https_response_headers(headers: &reqwest::header::HeaderMap) -> Vec { + headers + .iter() + .filter(|(name, _)| !is_sensitive_header(name.as_str())) + .take(PLUGIN_HTTPS_MAX_RESPONSE_HEADERS) + .filter_map(|(name, value)| { + value.to_str().ok().map(|value| PluginHttpsHeader { + name: name.as_str().to_string(), + value: bounded_header_value(value), + }) + }) + .collect() +} + +fn bounded_header_value(value: &str) -> String { + let mut redacted = redact_secret_like(value); + if redacted.len() > PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES { + truncate_string_to_boundary(&mut redacted, PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES); + redacted.push('…'); + } + redacted +} + +fn is_sensitive_header(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "authorization" + | "proxy-authorization" + | "cookie" + | "set-cookie" + | "x-api-key" + | "x-auth-token" + | "api-key" + | "apikey" + ) +} + +fn safe_url(url: &reqwest::Url) -> String { + let host = url.host_str().unwrap_or(""); + let mut path = url.path().to_string(); + if path.len() > 120 { + truncate_string_to_boundary(&mut path, 120); + path.push('…'); + } + match url.port() { + Some(port) => format!("https://{host}:{port}{path}"), + None => format!("https://{host}{path}"), + } +} + +fn redact_secret_like(message: &str) -> String { + let mut value = message.to_string(); + for needle in [ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "x-auth-token", + "api-key", + "token", + "secret", + "password", + ] { + value = redact_after_secret_word(&value, needle); + } + value +} + +fn redact_after_secret_word(input: &str, needle: &str) -> String { + let lower = input.to_ascii_lowercase(); + let mut out = String::new(); + let mut cursor = 0usize; + while let Some(relative) = lower[cursor..].find(needle) { + let start = cursor + relative; + let mut end = start + needle.len(); + out.push_str(&input[cursor..end]); + let bytes = input.as_bytes(); + while end < input.len() && matches!(bytes[end], b' ' | b'=' | b':' | b'\t') { + out.push(bytes[end] as char); + end += 1; + } + let secret_start = end; + while end < input.len() && !matches!(bytes[end], b' ' | b',' | b';' | b'\n' | b'\r') { + end += 1; + } + if end > secret_start { + out.push_str(PLUGIN_HTTPS_REDACTION); + } + cursor = end; + } + out.push_str(&input[cursor..]); + out +} + #[derive(Debug)] struct PluginPermissionError(String); @@ -426,9 +866,19 @@ fn authorize_plugin_host_api( &permission, &format!("granted host_api.{api} permission is missing"), )?; - Err(PluginPermissionError(format!( - "host_api.{api} is not implemented" - ))) + match api { + PluginHostApi::Https => { + if !has_usable_https_grant(record) { + return Err(PluginPermissionError( + "granted host_api.https allowlist is missing".to_string(), + )); + } + Ok(()) + } + PluginHostApi::Fs => Err(PluginPermissionError(format!( + "host_api.{api} is not implemented" + ))), + } } fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> { @@ -472,6 +922,78 @@ const PLUGIN_WASM_FUEL: u64 = 5_000_000; const PLUGIN_WASM_TIMEOUT: Duration = Duration::from_secs(1); const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024; const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256; +const PLUGIN_WASM_HTTPS_MODULE: &str = "yoi:https"; +const PLUGIN_HTTPS_MAX_REQUEST_BYTES: usize = 48 * 1024; +const PLUGIN_HTTPS_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024; +const PLUGIN_HTTPS_MAX_REQUEST_HEADERS: usize = 16; +const PLUGIN_HTTPS_MAX_RESPONSE_HEADERS: usize = 16; +const PLUGIN_HTTPS_MAX_HEADER_NAME_BYTES: usize = 64; +const PLUGIN_HTTPS_MAX_HEADER_VALUE_BYTES: usize = 1024; +const PLUGIN_HTTPS_MAX_RESPONSE_BYTES: usize = 64 * 1024; +const PLUGIN_HTTPS_TIMEOUT: Duration = Duration::from_secs(5); +const PLUGIN_HTTPS_ALLOWED_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"]; +const PLUGIN_HTTPS_REDACTION: &str = ""; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginHttpsRequest { + method: String, + url: String, + #[serde(default)] + headers: Vec, + #[serde(default)] + body: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginHttpsHeader { + name: String, + value: String, +} + +#[derive(Clone, Debug, Serialize)] +struct PluginHttpsResponse { + status: u16, + headers: Vec, + body: String, + truncated: bool, +} + +#[derive(Clone, Copy, Debug)] +struct PluginHttpsLimits { + timeout: Duration, + max_response_bytes: usize, +} + +impl Default for PluginHttpsLimits { + fn default() -> Self { + Self { + timeout: PLUGIN_HTTPS_TIMEOUT, + max_response_bytes: PLUGIN_HTTPS_MAX_RESPONSE_BYTES, + } + } +} + +trait PluginHttpsClient: Send + Sync { + fn execute( + &self, + request: &PluginHttpsRequest, + url: &reqwest::Url, + limits: PluginHttpsLimits, + ) -> Result; +} + +struct ReqwestPluginHttpsClient; + +#[derive(Debug)] +struct PluginHttpsError(String); + +impl PluginHttpsError { + fn new(message: impl Into) -> Self { + Self(redact_secret_like(&bounded_message(message.into()))) + } +} fn plugin_wasm_tool_definition( record: ResolvedPluginRecord, @@ -574,12 +1096,14 @@ impl PluginWasmError { } } -#[derive(Debug)] struct PluginWasmHostState { + record: ResolvedPluginRecord, + https_client: Arc, tool_name: Vec, input: Vec, output: Vec, output_error: Option, + https_response: Vec, store_limits: wasmi::StoreLimits, } @@ -587,6 +1111,20 @@ fn run_plugin_wasm_tool( record: ResolvedPluginRecord, tool_name: String, input: Vec, +) -> Result { + run_plugin_wasm_tool_with_https_client( + record, + tool_name, + input, + Arc::new(ReqwestPluginHttpsClient), + ) +} + +fn run_plugin_wasm_tool_with_https_client( + record: ResolvedPluginRecord, + tool_name: String, + input: Vec, + https_client: Arc, ) -> Result { let tool = record .manifest @@ -632,10 +1170,13 @@ fn run_plugin_wasm_tool( let mut store = wasmi::Store::new( &engine, PluginWasmHostState { + record: record.clone(), + https_client, tool_name: tool_name.into_bytes(), input, output: Vec::new(), output_error: None, + https_response: Vec::new(), store_limits, }, ); @@ -667,35 +1208,48 @@ fn validate_wasm_imports( module: &wasmi::Module, ) -> Result<(), PluginWasmError> { for import in module.imports() { - if import.module() == "yoi:https" { - authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - } - if import.module() == "yoi:fs" { - authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - } - if import.module() != PLUGIN_WASM_HOST_MODULE { - return Err(PluginWasmError::Module(format!( - "unsupported import module `{}`; only `{}` is available", - import.module(), - PLUGIN_WASM_HOST_MODULE - ))); - } - match import.name() { - "tool_name_len" | "tool_name_read" | "input_len" | "input_read" | "output_write" => {} + match import.module() { + PLUGIN_WASM_HOST_MODULE => match import.name() { + "tool_name_len" | "tool_name_read" | "input_len" | "input_read" + | "output_write" => {} + other => { + return Err(PluginWasmError::Module(format!( + "unsupported host import `{}`; no filesystem, ambient network, environment, or WASI imports are available", + other + ))); + } + }, + PLUGIN_WASM_HTTPS_MODULE => { + authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + match import.name() { + "request" | "response_len" | "response_read" => {} + other => { + return Err(PluginWasmError::Module(format!( + "unsupported https host import `{other}`" + ))); + } + } + } + "yoi:fs" => { + authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + return Err(PluginWasmError::Module( + "host_api.fs is not implemented".to_string(), + )); + } other => { return Err(PluginWasmError::Module(format!( - "unsupported host import `{}`; no filesystem, network, environment, or WASI imports are available", - other + "unsupported import module `{}`; only `{}` and `{}` are available", + other, PLUGIN_WASM_HOST_MODULE, PLUGIN_WASM_HTTPS_MODULE ))); } } @@ -751,6 +1305,33 @@ fn define_plugin_wasm_host_imports( }, ) .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HTTPS_MODULE, + "request", + |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { + read_guest_https_request(&mut caller, ptr, len) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HTTPS_MODULE, + "response_len", + |caller: wasmi::Caller<'_, PluginWasmHostState>| -> i32 { + caller.data().https_response.len() as i32 + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + linker + .func_wrap( + PLUGIN_WASM_HTTPS_MODULE, + "response_read", + |mut caller: wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, len: i32| -> i32 { + write_host_bytes_to_guest(&mut caller, ptr, len, HostBuffer::HttpsResponse) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; Ok(()) } @@ -758,6 +1339,7 @@ fn define_plugin_wasm_host_imports( enum HostBuffer { ToolName, Input, + HttpsResponse, } fn write_host_bytes_to_guest( @@ -772,6 +1354,7 @@ fn write_host_bytes_to_guest( let bytes = match buffer { HostBuffer::ToolName => caller.data().tool_name.clone(), HostBuffer::Input => caller.data().input.clone(), + HostBuffer::HttpsResponse => caller.data().https_response.clone(), }; if len as usize != bytes.len() { return -1; @@ -788,6 +1371,58 @@ fn write_host_bytes_to_guest( } } +fn read_guest_https_request( + caller: &mut wasmi::Caller<'_, PluginWasmHostState>, + ptr: i32, + len: i32, +) -> i32 { + let bytes = match read_guest_bytes(caller, ptr, len, PLUGIN_HTTPS_MAX_REQUEST_BYTES) { + Ok(bytes) => bytes, + Err(error) => { + caller.data_mut().output_error = Some(error); + return -1; + } + }; + let record = caller.data().record.clone(); + let https_client = caller.data().https_client.clone(); + match execute_plugin_https_request(&record, https_client.as_ref(), &bytes) { + Ok(response) => { + caller.data_mut().https_response = response; + caller.data().https_response.len() as i32 + } + Err(error) => { + caller.data_mut().output_error = Some(error.0); + -1 + } + } +} + +fn read_guest_bytes( + caller: &mut wasmi::Caller<'_, PluginWasmHostState>, + ptr: i32, + len: i32, + max_len: usize, +) -> Result, String> { + if ptr < 0 || len < 0 { + return Err("guest input pointer/length is invalid".into()); + } + let len = len as usize; + if len > max_len { + return Err(format!("guest input exceeds {max_len} bytes")); + } + let Some(memory) = caller + .get_export("memory") + .and_then(|export| export.into_memory()) + else { + return Err("guest did not export linear memory".into()); + }; + let mut bytes = vec![0; len]; + memory + .read(&*caller, ptr as usize, &mut bytes) + .map_err(|_| "guest input memory range is invalid".to_string())?; + Ok(bytes) +} + fn read_guest_output( caller: &mut wasmi::Caller<'_, PluginWasmHostState>, ptr: i32, @@ -1115,12 +1750,13 @@ mod tests { use super::*; use manifest::plugin::{ PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig, - PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId, + PluginHttpsGrant, PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup, }; use serde_json::json; use std::fs; use std::path::Path; + use std::sync::Mutex; use tempfile::TempDir; fn tool(name: &str) -> manifest::plugin::PluginToolManifest { @@ -1167,6 +1803,7 @@ mod tests { version: Some(PluginExactVersion("0.1.0".to_string())), digest: Some("sha256:abc".to_string()), permissions, + https: Vec::new(), }, config: None, } @@ -1213,6 +1850,117 @@ mod tests { (report, pending) } + fn record_with_https_grant() -> ResolvedPluginRecord { + let mut record = record(vec![tool("HttpsTool")]); + let https_permission = PluginPermission::HostApi { + api: PluginHostApi::Https, + }; + record.manifest.permissions.push(https_permission.clone()); + record.grants.permissions.push(https_permission); + record.grants.https.push(PluginHttpsGrant { + host: "api.example.test".to_string(), + methods: vec!["GET".to_string(), "POST".to_string()], + path_prefixes: vec!["/v1".to_string()], + }); + record + } + + struct MockHttpsClient { + calls: Mutex, + response_body: String, + error: Mutex>, + } + + impl Default for MockHttpsClient { + fn default() -> Self { + Self { + calls: Mutex::new(0), + response_body: "ok".to_string(), + error: Mutex::new(None), + } + } + } + + impl MockHttpsClient { + fn call_count(&self) -> usize { + *self.calls.lock().expect("mock call lock") + } + } + + impl PluginHttpsClient for MockHttpsClient { + fn execute( + &self, + _request: &PluginHttpsRequest, + _url: &reqwest::Url, + _limits: PluginHttpsLimits, + ) -> Result { + *self.calls.lock().expect("mock call lock") += 1; + if let Some(error) = self.error.lock().expect("mock error lock").take() { + return Err(PluginHttpsError::new(error)); + } + Ok(PluginHttpsResponse { + status: 200, + headers: vec![PluginHttpsHeader { + name: "content-type".to_string(), + value: "text/plain".to_string(), + }], + body: self.response_body.clone(), + truncated: false, + }) + } + } + + fn https_request_json(method: &str, url: &str) -> String { + json!({ "method": method, "url": url }).to_string() + } + + fn wasm_tool_that_calls_https(request: &str) -> Vec { + let output = br#"{"summary":"https ok","content":"ordinary tool result path"}"#; + wat::parse_str(format!( + r#" + (module + (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) + (import "yoi:https" "request" (func $https_request (param i32 i32) (result i32))) + (import "yoi:https" "response_len" (func $https_response_len (result i32))) + (memory (export "memory") 1) + (data (i32.const 16) "{}") + (data (i32.const 4096) "{}") + (func (export "yoi_tool_call") + (local $n i32) + (local.set $n (call $https_request (i32.const 16) (i32.const {}))) + (if (i32.lt_s (local.get $n) (i32.const 0)) (then unreachable)) + (drop (call $https_response_len)) + (drop (call $output_write (i32.const 4096) (i32.const {}))) + ) + ) + "#, + wat_bytes(request.as_bytes()), + wat_bytes(output), + request.len(), + output.len() + )) + .expect("valid wat") + } + + fn empty_wasm_tool() -> Vec { + let output = br#"{"summary":"no network","content":"no https import"}"#; + wat::parse_str(format!( + r#" + (module + (import "yoi:tool" "output_write" (func $output_write (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 4096) "{}") + (func (export "yoi_tool_call") + (drop (call $output_write (i32.const 4096) (i32.const {}))) + ) + ) + "#, + wat_bytes(output), + output.len() + )) + .expect("valid wat") + } + #[test] fn rejects_invalid_root_schema() { let schema = json!({"type":"string"}); @@ -1309,6 +2057,129 @@ mod tests { assert_eq!(origin.surface, "tool"); } + #[test] + fn wasm_tool_can_call_granted_https_host_api() { + let (_dir, record) = runtime_record_with_https_wasm(wasm_tool_that_calls_https( + &https_request_json("GET", "https://api.example.test/v1/data"), + )); + let client = Arc::new(MockHttpsClient::default()); + let output = run_plugin_wasm_tool_with_https_client( + record, + "PluginEcho".to_string(), + Vec::new(), + client.clone(), + ) + .expect("tool output"); + assert_eq!(client.call_count(), 1); + assert_eq!(output.summary, "https ok"); + assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); + } + + #[test] + fn missing_https_grant_denies_before_network() { + let (_dir, mut record) = resolved_record_with_wasm(wasm_tool_that_calls_https( + &https_request_json("GET", "https://api.example.test/v1/data"), + )); + record.manifest.permissions.push(PluginPermission::HostApi { + api: PluginHostApi::Https, + }); + let client = Arc::new(MockHttpsClient::default()); + let error = run_plugin_wasm_tool_with_https_client( + record, + "PluginEcho".to_string(), + Vec::new(), + client.clone(), + ) + .expect_err("grant denied"); + assert_eq!(client.call_count(), 0); + assert!(error.bounded_message().contains("host_api.https")); + } + + #[test] + fn disallowed_https_request_targets_deny_before_network() { + let record = record_with_https_grant(); + let client = MockHttpsClient::default(); + for (method, url, needle) in [ + ("GET", "http://api.example.test/v1/data", "scheme"), + ("TRACE", "https://api.example.test/v1/data", "method"), + ("GET", "https://other.example.test/v1/data", "grants"), + ("GET", "https://localhost/v1/data", "local/private"), + ("GET", "https://127.0.0.1/v1/data", "local/private"), + ("GET", "https://10.0.0.1/v1/data", "local/private"), + ("GET", "https://169.254.169.254/v1/data", "local/private"), + ("GET", "file:///tmp/secret", "scheme"), + ] { + let error = execute_plugin_https_request( + &record, + &client, + https_request_json(method, url).as_bytes(), + ) + .expect_err("request denied"); + assert!( + error.0.contains(needle), + "{method} {url} produced {:?}, expected {needle}", + error.0 + ); + } + assert_eq!(client.call_count(), 0); + } + + #[test] + fn timeout_and_secret_diagnostics_are_bounded_and_redacted() { + let record = record_with_https_grant(); + let client = MockHttpsClient::default(); + *client.error.lock().expect("mock error lock") = + Some("timeout while using Authorization: Bearer SUPER_SECRET_TOKEN".to_string()); + let error = execute_plugin_https_request( + &record, + &client, + https_request_json("GET", "https://api.example.test/v1/data").as_bytes(), + ) + .expect_err("timeout error"); + assert!(error.0.contains("timeout")); + assert!(error.0.contains(PLUGIN_HTTPS_REDACTION)); + assert!(!error.0.contains("SUPER_SECRET_TOKEN")); + assert!(error.0.len() <= 513); + assert_eq!(client.call_count(), 1); + } + + #[test] + fn response_size_bound_truncates() { + let record = record_with_https_grant(); + let client = MockHttpsClient { + calls: Mutex::new(0), + response_body: "x".repeat(PLUGIN_HTTPS_MAX_RESPONSE_BYTES + 8), + error: Mutex::new(None), + }; + let response = execute_plugin_https_request( + &record, + &client, + https_request_json("GET", "https://api.example.test/v1/data").as_bytes(), + ) + .expect("response"); + let value: Value = serde_json::from_slice(&response).expect("response json"); + assert_eq!(value["truncated"], true); + assert_eq!( + value["body"].as_str().expect("body").len(), + PLUGIN_HTTPS_MAX_RESPONSE_BYTES + ); + } + + #[test] + fn no_network_without_https_import() { + let (_dir, record) = runtime_record_with_https_wasm(empty_wasm_tool()); + let client = Arc::new(MockHttpsClient::default()); + let output = run_plugin_wasm_tool_with_https_client( + record, + "PluginEcho".to_string(), + Vec::new(), + client.clone(), + ) + .expect("tool output"); + assert_eq!(client.call_count(), 0); + assert_eq!(output.summary, "no network"); + } + #[test] fn enabled_plugin_tool_registers_model_visible_schema_and_origin() { let mut pending = Vec::new(); @@ -1472,7 +2343,7 @@ mod tests { .unwrap_err() .bounded_message(); assert!( - error.contains("host_api.https is not implemented"), + error.contains("granted host_api.https allowlist is missing"), "{error}" ); } @@ -1688,6 +2559,21 @@ mod tests { record } + fn runtime_record_with_https_wasm(wasm: Vec) -> (TempDir, ResolvedPluginRecord) { + let (dir, mut record) = resolved_record_with_wasm(wasm); + let https_permission = PluginPermission::HostApi { + api: PluginHostApi::Https, + }; + record.manifest.permissions.push(https_permission.clone()); + record.grants.permissions.push(https_permission); + record.grants.https.push(PluginHttpsGrant { + host: "api.example.test".to_string(), + methods: vec!["GET".to_string(), "POST".to_string()], + path_prefixes: vec!["/v1".to_string()], + }); + (dir, record) + } + fn resolved_record_with_wasm(wasm: Vec) -> (TempDir, ResolvedPluginRecord) { let dir = TempDir::new().unwrap(); let package_dir = dir.path().join(".yoi/plugins"); @@ -1717,6 +2603,7 @@ mod tests { version: Some(PluginExactVersion(record.version.clone())), digest: Some(record.digest.clone()), permissions: tool_permissions(&record.manifest.tools), + https: Vec::new(), }; (dir, record) } diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 6cf9264f..0b8b362f 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -185,6 +185,11 @@ fn render_item_human(item: &PluginInspectionItem) -> Result { " configured_grants: {}", join_or_none(&item.configured_grants) )?; + writeln!( + out, + " configured_https_grants: {}", + join_or_none(&item.configured_https_grants) + )?; if let Some(runtime) = &item.static_runtime { writeln!( @@ -354,6 +359,7 @@ fn snapshot_from_resolution( builder.configured = true; builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied()); builder.configured_grants = permission_strings(&enablement.grants.permissions); + builder.configured_https_grants = https_grant_strings(&enablement.grants.https); if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) { builder .source @@ -445,6 +451,7 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) { builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied()); builder.requested_permissions = permission_strings(&resolved.manifest.permissions); builder.configured_grants = permission_strings(&resolved.grants.permissions); + builder.configured_https_grants = https_grant_strings(&resolved.grants.https); let record = ResolvedPluginRecord::from_resolved(resolved); let static_runtime = inspect_resolved_plugin_static(&record); @@ -540,6 +547,13 @@ fn permission_strings(permissions: &[PluginPermission]) -> Vec { values } +fn https_grant_strings(grants: &[manifest::plugin::PluginHttpsGrant]) -> Vec { + let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect(); + values.sort(); + values.dedup(); + values +} + fn permission_requested(manifest: &PluginPackageManifest, permission: &PluginPermission) -> bool { manifest .permissions @@ -610,6 +624,7 @@ struct PluginInspectionItem { enabled_surfaces: Vec, requested_permissions: Vec, configured_grants: Vec, + configured_https_grants: Vec, tools: Vec, static_runtime: Option, diagnostics: Vec, @@ -677,6 +692,7 @@ struct ItemBuilder { enabled_surfaces: Vec, requested_permissions: Vec, configured_grants: Vec, + configured_https_grants: Vec, tools: Vec, static_runtime: Option, diagnostics: Vec, @@ -702,6 +718,7 @@ impl ItemBuilder { enabled_surfaces: Vec::new(), requested_permissions: Vec::new(), configured_grants: Vec::new(), + configured_https_grants: Vec::new(), tools: Vec::new(), static_runtime: None, diagnostics: Vec::new(), @@ -772,6 +789,7 @@ impl ItemBuilder { enabled_surfaces: self.enabled_surfaces, requested_permissions: self.requested_permissions, configured_grants: self.configured_grants, + configured_https_grants: self.configured_https_grants, tools: self.tools, static_runtime: self.static_runtime, diagnostics: self.diagnostics, @@ -864,6 +882,7 @@ mod tests { PluginPermission::surface(PluginSurface::Tool), PluginPermission::tool("Echo"), ], + https: Vec::new(), }, config: None, }); @@ -880,6 +899,7 @@ mod tests { PluginPermission::surface(PluginSurface::Tool), PluginPermission::tool("Echo"), ], + https: Vec::new(), }, config: None, }); @@ -996,6 +1016,7 @@ mod tests { PluginPermission::surface(PluginSurface::Tool), PluginPermission::tool("Echo"), ], + https: Vec::new(), }, config: None, }); @@ -1258,6 +1279,7 @@ mod tests { PluginPermission::surface(PluginSurface::Tool), PluginPermission::tool("Echo"), ], + https: Vec::new(), }, config: None, }], @@ -1287,6 +1309,7 @@ mod tests { version: Some(PluginExactVersion(version.to_string())), digest: Some(digest), permissions, + https: Vec::new(), }, config: None, } @@ -1313,6 +1336,7 @@ mod tests { version: Some(PluginExactVersion(version.to_string())), digest: None, permissions, + https: Vec::new(), }, config: None, } diff --git a/package.nix b/package.nix index df9a6d74..40f7d23c 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-ud+3INcXnT5W26Bz0K4QXUqoqw3p/ER9c4F2Fhq3YuQ="; + cargoHash = "sha256-xqax43t9IevkNG2lZvfRP562ORKb3aHxUNsQwS1FK/k="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,