diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index b689e9cc56..a15d481b58 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -92,6 +92,10 @@ pub struct HostGrants { /// address, one hop at a time, with no cookies; never pages. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub public_images: bool, + /// `readPage` may read the preview fields of a web page on any public + /// HTTPS address, one hop at a time, with no cookies; never its HTML. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub public_pages: bool, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -2270,6 +2274,21 @@ mod tests { ); images["host"]["publicImages"] = serde_json::json!("true"); assert!(serde_json::from_value::(images).is_err()); + assert!(!old.host.as_ref().unwrap().public_pages); + let mut pages = manifest.clone(); + pages["host"]["publicPages"] = serde_json::json!(false); + let parsed: Manifest = serde_json::from_value(pages.clone()).unwrap(); + assert_eq!(serde_json::to_value(&parsed).unwrap(), manifest); + pages["host"]["publicPages"] = serde_json::json!(true); + let parsed: Manifest = serde_json::from_value(pages.clone()).unwrap(); + assert!(parsed.host.as_ref().unwrap().public_pages); + assert_eq!(serde_json::to_value(&parsed).unwrap(), pages); + assert_ne!( + artifact_from_text(&manifest.to_string(), "export const x = 1".into()).unwrap(), + artifact_from_text(&pages.to_string(), "export const x = 1".into()).unwrap() + ); + pages["host"]["publicPages"] = serde_json::json!("true"); + assert!(serde_json::from_value::(pages).is_err()); for limit in [1, 4096, MAX_HOST_COMMAND_OUTPUT_BYTES] { let mut valid = manifest.clone(); valid["host"]["commands"][0]["maxOutputBytes"] = serde_json::json!(limit); diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 643a960b50..cf0275b7c3 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -1809,6 +1809,20 @@ Names that resolve publicly but are reached only through a VPN (such as a company's internal hosts) are not visible to this check, so the plugin must refuse them by name on every hop. The site sees the user's IP address. +Declare `host: { publicPages: true }` to let `readPage(url, { timeoutMs? })` +read a link preview's fields from any public HTTPS page. It pins addresses and +follows no redirect, exactly as `publicImages` does, and sends +`Accept: text/html,application/xhtml+xml`. A redirect returns +`{ status, location }`; a 2xx HTML page returns `{ status, preview }`; anything +else returns `{ status }`. Native code reads at most the page's head (512 KiB, +stopping at `` or ``) and returns only `title`, `description`, +`siteName`, `image`, `imageAlt` and `icon`, preferring Open Graph over Twitter +card tags over `` and `<meta name="description">`, with entities decoded, +whitespace collapsed and lengths capped. `image` and `icon` are absolute HTTPS +URLs; read the image with `requestBytes` under `publicImages`. The page's HTML +never reaches the plugin. The timeout defaults to 10000 ms and can be +1–30000 ms. The same VPN caveat applies: refuse internal names on every hop. + Only images come back, but the plugin chooses each URL, so the grant lets it send data it holds to any public site in the path, query or hostname. It lifts the bound `networkOrigins` otherwise puts on where a plugin's data can go; the diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index f2d55188e1..a2303b9f72 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -122,7 +122,7 @@ Add `"host"` to `inject`, then: - `await ctx.host.runCommand("status", input?)` runs a declared command (no shell, 5 s deadline, stdin only if `maxInputBytes` is declared). It returns stdout (4096 bytes by default, up to `maxOutputBytes`, at most 1048576), or `null` on failure or when output exceeds the bound. A command may declare `artifact: { path, sha256, siblings?: [{ name, sha256 }] }` (an absolute or `~/…` path, lowercase SHA-256 hex, and files beside it that must match too) to run only those verified bytes, never a program on PATH; `program` is then just its label and the declared `args` are passed to it. Interpreters and programs it calls by name are still found on PATH, so give it an absolute shebang such as `#!/bin/sh` and call programs by absolute path. - `await ctx.host.request({ url, method?, headers?, body?, timeoutMs? })` returns `{ status, headers, body }` and throws on an undeclared origin or timeout. `url` must use an exact declared HTTPS origin (origin only in the manifest: scheme + host, no path or trailing slash). No redirects, no cookies. Plain `fetch` to other origins is blocked by the app's content security policy, so use this. - `ctx.host.spawn?.(id, { args?, cwd?, env?, onStdout?, onStderr? })` starts a declared `processes` entry (desktop only; check it exists). Returns `{ write, end, kill, exited }`. Your `args` follow the declared ones. -- `files: true` enables `ctx.host.readFile(path, { cwd? })` (bytes, at most 50 MB) and `ctx.host.resolveDirectory(path)`. Other grants: `mcp: true` (`host.serveMcp`), `managedFiles: true`, `publicImages: true` (`host.requestBytes` may also read PNG, JPEG, GIF, WebP and AVIF images from any public HTTPS site, one hop at a time; check each redirect's `Location` yourself), `agentHooks: ["plugin/kind", ...]` (agent type keys such as `buzz.claude-code/claude-code`). +- `files: true` enables `ctx.host.readFile(path, { cwd? })` (bytes, at most 50 MB) and `ctx.host.resolveDirectory(path)`. Other grants: `mcp: true` (`host.serveMcp`), `managedFiles: true`, `publicImages: true` (`host.requestBytes` may also read PNG, JPEG, GIF, WebP and AVIF images from any public HTTPS site, one hop at a time; check each redirect's `Location` yourself), `publicPages: true` (`host.readPage(url)` returns a public HTTPS page's title, description, site name, image and icon, never its HTML; redirects come back as `location`), `agentHooks: ["plugin/kind", ...]` (agent type keys such as `buzz.claude-code/claude-code`). ## UI diff --git a/src-tauri/build.rs b/src-tauri/build.rs index e8e0d0c122..e89efd798e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -174,6 +174,7 @@ fn main() { "plugin_host_call_context", "plugin_host_request", "plugin_host_request_bytes", + "plugin_host_read_page", "plugin_host_upload_bytes_once", "plugin_host_save_bytes", "plugin_host_delete_managed_file", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 485fb285fd..213ca5c72d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -120,6 +120,7 @@ "allow-plugin-host-call-context", "allow-plugin-host-request", "allow-plugin-host-request-bytes", + "allow-plugin-host-read-page", "allow-plugin-host-upload-bytes-once", "allow-plugin-host-save-bytes", "allow-plugin-host-delete-managed-file", diff --git a/src-tauri/src/host_request.rs b/src-tauri/src/host_request.rs index e83cfea932..a151046a47 100644 --- a/src-tauri/src/host_request.rs +++ b/src-tauri/src/host_request.rs @@ -8,6 +8,7 @@ use sha2::{Digest, Sha256}; use std::sync::{Mutex, OnceLock}; mod public_image; +mod public_page; #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -480,6 +481,47 @@ pub(crate) async fn plugin_host_request_bytes( .map_err(|_| "Host byte request timed out")? } +/// One public page's preview fields, for a plugin whose manifest declares +/// `host.publicPages`. Redirects come back for the plugin to check. +#[tauri::command] +pub(crate) async fn plugin_host_read_page( + manager: tauri::State<'_, PluginManager>, + id: String, + revision: String, + url: String, + timeout_ms: Option<u64>, +) -> Result<public_page::PageReply, String> { + let timeout = timeout_ms.unwrap_or(10_000); + if !(1..=30_000).contains(&timeout) { + return Err("Invalid page request timeout".into()); + } + let (url, _, _) = validate_request(&HostRequest { + url, + method: "GET".into(), + headers: BTreeMap::new(), + body: None, + timeout_ms: Some(timeout), + })?; + let end = tokio::time::Instant::now() + Duration::from_millis(timeout); + let operation = async { + let _place = crate::host_files::byte_admission(end).await?; + if !byte_grants(manager.clone(), &id, &revision) + .await? + .public_pages + { + return Err("Plugin may not read public pages".to_owned()); + } + let reply = public_page::read(url, Duration::from_millis(timeout)).await?; + if !byte_grants(manager, &id, &revision).await?.public_pages { + return Err("Plugin may not read public pages".to_owned()); + } + Ok(reply) + }; + tokio::time::timeout_at(end, operation) + .await + .map_err(|_| "Host page request timed out")? +} + /// How a byte request may reach its URL: a declared origin gets any response; /// any other origin, with `host.publicImages`, gets only a public image. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src-tauri/src/host_request/public_image.rs b/src-tauri/src/host_request/public_image.rs index e343020f67..022955b148 100644 --- a/src-tauri/src/host_request/public_image.rs +++ b/src-tauri/src/host_request/public_image.rs @@ -96,14 +96,14 @@ fn embedded_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> { /// The addresses to connect to for `url`, when its port is 443 and every /// address its name resolves to is public. One private answer refuses all. pub(super) async fn public_addresses(url: &reqwest::Url) -> Result<Vec<SocketAddr>, String> { - const REFUSED: &str = "Public image host is not a public address"; + const REFUSED: &str = "Public host is not a public address"; if url.port_or_known_default() != Some(443) { - return Err("Public images are read from port 443 only".into()); + return Err("Public hosts are read from port 443 only".into()); } let addresses: Vec<SocketAddr> = match url.host() { Some(url::Host::Domain(name)) => tokio::net::lookup_host((name, 443)) .await - .map_err(|_| "Public image host did not resolve")? + .map_err(|_| "Public host did not resolve")? .collect(), Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(ip.into(), 443)], Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(ip.into(), 443)], @@ -123,14 +123,19 @@ pub(super) async fn fetch( max_bytes: usize, timeout: Duration, ) -> Result<Reply, String> { - read_image(request(url, addresses, timeout)?, max_bytes).await + read_image( + request(url, addresses, &TYPES.join(","), timeout)?, + max_bytes, + ) + .await } -/// The GET `fetch` sends, on a client of its own that can reach only -/// `addresses` for the URL's name. -fn request( +/// A GET for `url`, on a client of its own that can reach only `addresses` +/// for the URL's name. +pub(super) fn request( url: reqwest::Url, addresses: &[SocketAddr], + accept: &str, timeout: Duration, ) -> Result<reqwest::RequestBuilder, String> { let mut builder = reqwest::Client::builder() @@ -148,7 +153,7 @@ fn request( .map_err(|_| "Host request client is unavailable")?; Ok(client .get(url) - .header(reqwest::header::ACCEPT, TYPES.join(",")) + .header(reqwest::header::ACCEPT, accept) .timeout(timeout)) } @@ -305,7 +310,7 @@ mod tests { let (address, server) = reply(response); let url = reqwest::Url::parse(&format!("http://image.invalid:{}/img", address.port())).unwrap(); - let request = request(url, &[address], Duration::from_secs(5))?; + let request = request(url, &[address], &TYPES.join(","), Duration::from_secs(5))?; let result = tauri::async_runtime::block_on(read_image(request, max)); let sent = server .recv_timeout(Duration::from_secs(5)) diff --git a/src-tauri/src/host_request/public_page.rs b/src-tauri/src/host_request/public_page.rs new file mode 100644 index 0000000000..7fb6ddd4d8 --- /dev/null +++ b/src-tauri/src/host_request/public_page.rs @@ -0,0 +1,576 @@ +//! `host.readPage` for a plugin granted `host.publicPages`: one GET for a web +//! page on any public HTTPS site, answered with its preview fields only. +//! +//! A link preview needs the title, description and image a page names in its +//! head, from a site nobody declared. This pins public addresses as +//! `public_image` does, follows no redirect (the plugin checks each `Location` +//! before asking again), reads at most the page's head and hands back only the +//! fields below, never the HTML, so the grant can't be used to read pages. +use super::public_image; +use serde::Serialize; +use std::net::SocketAddr; +use std::time::Duration; + +const ACCEPT: &str = "text/html,application/xhtml+xml"; +/// Pages put their head first; a head this long is not one worth reading on. +const MAX_HEAD_BYTES: usize = 512 * 1024; +const MAX_URL: usize = 2048; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PagePreview { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + site_name: Option<String>, + /// An absolute HTTPS URL, for the plugin to read with `host.publicImages`. + #[serde(skip_serializing_if = "Option::is_none")] + image: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + image_alt: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option<String>, +} + +/// A redirect's status and `Location`, a page's status and preview, or for +/// anything else its status alone. +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PageReply { + status: u16, + #[serde(skip_serializing_if = "Option::is_none")] + location: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + preview: Option<PagePreview>, +} + +pub(super) async fn read(url: reqwest::Url, timeout: Duration) -> Result<PageReply, String> { + let addresses = public_image::public_addresses(&url).await?; + read_from(url, &addresses, timeout).await +} + +async fn read_from( + url: reqwest::Url, + addresses: &[SocketAddr], + timeout: Duration, +) -> Result<PageReply, String> { + let base = url.clone(); + let request = public_image::request(url, addresses, ACCEPT, timeout)?; + let mut response = request.send().await.map_err(|_| "Host request failed")?; + let status = response.status().as_u16(); + let header = |name| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + let mut reply = PageReply { + status, + location: None, + preview: None, + }; + if (300..400).contains(&status) { + reply.location = header(reqwest::header::LOCATION).filter(|l| l.len() <= MAX_URL); + return Ok(reply); + } + let html = header(reqwest::header::CONTENT_TYPE) + .and_then(|value| { + value + .split(';') + .next() + .map(|t| t.trim().to_ascii_lowercase()) + }) + .is_some_and(|kind| kind == "text/html" || kind == "application/xhtml+xml"); + if !html || !(200..300).contains(&status) { + return Ok(reply); + } + let mut bytes = Vec::new(); + let mut boundary_seen = false; + while let Some(chunk) = response.chunk().await.map_err(|_| "Host response failed")? { + let from = bytes.len().saturating_sub(5); + let room = MAX_HEAD_BYTES - bytes.len(); + bytes.extend_from_slice(&chunk[..chunk.len().min(room)]); + boundary_seen = boundary_seen || may_end_head(&bytes[from..]); + if bytes.len() >= MAX_HEAD_BYTES + || (boundary_seen && scan(&String::from_utf8_lossy(&bytes)).ended) + { + break; + } + } + reply.preview = Some(preview(&String::from_utf8_lossy(&bytes), &base)); + Ok(reply) +} + +/// A cheap check for `</head` or `<body` bytes; `scan` decides whether they +/// end the head or sit inside a script, comment or attribute. +fn may_end_head(bytes: &[u8]) -> bool { + bytes + .windows(5) + .any(|w| w.eq_ignore_ascii_case(b"</hea") || w.eq_ignore_ascii_case(b"<body")) +} + +/// The preview fields `html`'s head names, with URLs resolved against `base`. +pub(crate) fn preview(html: &str, base: &reqwest::Url) -> PagePreview { + let Scan { + meta, title, icon, .. + } = scan(html); + let value = |field: Ranked| field.map(|(_, value)| value); + let mut images = meta.images; + images.sort_by_key(|(rank, _)| *rank); + PagePreview { + title: text(value(meta.title).or(title), 300), + description: text(value(meta.description), 600), + site_name: text(value(meta.site_name), 100), + image: images.iter().find_map(|(_, url)| https(url, base)), + image_alt: text(value(meta.image_alt), 300), + icon: icon.and_then(|url| https(&url, base)), + } +} + +struct Scan { + meta: Meta, + title: Option<String>, + icon: Option<String>, + ended: bool, +} + +fn scan(html: &str) -> Scan { + let mut meta = Meta::default(); + let mut title = None; + let mut icon = None; + let mut ended = false; + let lower = html.to_ascii_lowercase(); + let mut at = 0; + while let Some(offset) = lower[at..].find('<') { + let start = at + offset; + let rest = &lower[start..]; + if rest.starts_with("<!--") { + at = rest.find("-->").map_or(lower.len(), |end| start + end + 3); + continue; + } + let Some(end) = tag_end(&lower, start) else { + break; + }; + let name: String = rest[1..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '/') + .collect(); + at = end + 1; + match name.as_str() { + "/head" | "body" => { + ended = true; + break; + } + "meta" => { + let attributes = attributes(&html[start..end]); + let key = attribute(&attributes, "property") + .or_else(|| attribute(&attributes, "name")) + .map(str::to_ascii_lowercase); + if let (Some(key), Some(content)) = (key, attribute(&attributes, "content")) { + meta.set(&key, content); + } + } + "link" => { + let attributes = attributes(&html[start..end]); + let rel = attribute(&attributes, "rel").map(str::to_ascii_lowercase); + if icon.is_none() + && rel.is_some_and(|rel| rel.split_ascii_whitespace().any(|r| r == "icon")) + { + icon = attribute(&attributes, "href").map(str::to_owned); + } + } + "title" if title.is_none() => { + let close = lower[at..].find("</title").map_or(lower.len(), |i| at + i); + title = Some(html[at..close].to_owned()); + at = close; + } + "script" | "style" | "noscript" | "template" => { + let close = format!("</{name}"); + at = lower[at..].find(&close).map_or(lower.len(), |i| at + i); + } + _ => {} + } + } + Scan { + meta, + title, + icon, + ended, + } +} + +type Ranked = Option<(u8, String)>; + +#[derive(Default)] +struct Meta { + title: Ranked, + description: Ranked, + site_name: Ranked, + images: Vec<(u8, String)>, + image_alt: Ranked, +} + +/// Image candidates kept per page; the first, by rank, that is HTTPS wins. +const MAX_IMAGES: usize = 8; + +impl Meta { + /// Open Graph wins over Twitter cards, which win over plain `description`. + fn set(&mut self, key: &str, content: &str) { + let (field, rank) = match key { + "og:title" => (&mut self.title, 0), + "twitter:title" => (&mut self.title, 1), + "og:description" => (&mut self.description, 0), + "twitter:description" => (&mut self.description, 1), + "description" => (&mut self.description, 2), + "og:site_name" => (&mut self.site_name, 0), + "og:image" | "og:image:url" | "og:image:secure_url" => return self.image(0, content), + "twitter:image" | "twitter:image:src" => return self.image(1, content), + "og:image:alt" => (&mut self.image_alt, 0), + "twitter:image:alt" => (&mut self.image_alt, 1), + _ => return, + }; + if !content.trim().is_empty() && field.as_ref().is_none_or(|(held, _)| rank < *held) { + *field = Some((rank, content.to_owned())); + } + } + + fn image(&mut self, rank: u8, content: &str) { + if !content.trim().is_empty() && self.images.len() < MAX_IMAGES { + self.images.push((rank, content.to_owned())); + } + } +} + +/// The `>` closing the tag at `start`, outside quoted attribute values. +fn tag_end(html: &str, start: usize) -> Option<usize> { + let mut quote = None; + for (i, c) in html[start..].char_indices() { + match (quote, c) { + (None, '"' | '\'') => quote = Some(c), + (Some(q), c) if c == q => quote = None, + (None, '>') => return Some(start + i), + _ => {} + } + } + None +} + +/// A tag's attributes, names lowercased, values as written. +fn attributes(tag: &str) -> Vec<(String, &str)> { + let mut out = Vec::new(); + let body = tag.trim_start_matches('<'); + let body = body.trim_start_matches(|c: char| c.is_ascii_alphanumeric()); + let mut rest = body; + loop { + rest = rest.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '/'); + if rest.is_empty() { + break; + } + let name_end = rest + .find(|c: char| c.is_ascii_whitespace() || c == '=' || c == '/') + .unwrap_or(rest.len()); + let name = rest[..name_end].to_ascii_lowercase(); + rest = rest[name_end..].trim_start(); + let value = if let Some(after) = rest.strip_prefix('=') { + let after = after.trim_start(); + match after.chars().next() { + Some(q @ ('"' | '\'')) => { + let close = after[1..].find(q).map_or(after.len(), |i| i + 1); + let value = &after[1..close]; + rest = after.get(close + 1..).unwrap_or(""); + value + } + _ => { + let end = after + .find(|c: char| c.is_ascii_whitespace()) + .unwrap_or(after.len()); + rest = &after[end..]; + &after[..end] + } + } + } else { + "" + }; + if name.is_empty() { + break; + } + out.push((name, value)); + } + out +} + +fn attribute<'a>(attributes: &[(String, &'a str)], name: &str) -> Option<&'a str> { + attributes + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| *value) +} + +/// `raw` with entities decoded, whitespace collapsed, and at most `max` +/// characters, or nothing when empty. +fn text(raw: Option<String>, max: usize) -> Option<String> { + let decoded = decode(&raw?); + let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" "); + let mut chars = collapsed.chars(); + let mut out: String = chars.by_ref().take(max).collect(); + if chars.next().is_some() { + out.pop(); + out.push('…'); + } + (!out.is_empty()).then_some(out) +} + +fn decode(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let end = rest[1..].find(';').map(|e| e + 1).filter(|e| *e <= 10); + let decoded = end.and_then(|end| { + let name = &rest[1..end]; + let c = match name { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ => name + .strip_prefix("#x") + .or_else(|| name.strip_prefix("#X")) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| name.strip_prefix('#').and_then(|n| n.parse().ok())) + .and_then(char::from_u32), + }?; + Some((c, end)) + }); + match decoded { + Some((c, end)) => { + out.push(c); + rest = &rest[end + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +fn https(raw: &str, base: &reqwest::Url) -> Option<String> { + let url = base.join(decode(raw).trim()).ok()?; + (url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.as_str().len() <= MAX_URL) + .then(|| url.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read as _, Write as _}; + + fn base() -> reqwest::Url { + reqwest::Url::parse("https://example.com/a/page").unwrap() + } + + #[test] + fn open_graph_wins_over_twitter_and_the_title() { + let html = r#"<!doctype html><html><head> + <title>Plain & simple + + + + + + + + + "#; + assert_eq!( + preview(html, &base()), + PagePreview { + title: Some("OG \"title\"".into()), + description: Some("Twitter description".into()), + site_name: Some("Example".into()), + image: Some("https://example.com/img/card.png".into()), + image_alt: Some("A card".into()), + icon: Some("https://cdn.example.com/favicon.ico".into()), + } + ); + } + + #[test] + fn the_title_tag_fills_in_and_scripts_and_comments_are_skipped() { + let html = "\ + \ + \n Hello — world \n"; + let page = preview(html, &base()); + assert_eq!(page.title.as_deref(), Some("Hello — world")); + assert_eq!(page.description, None); + } + + #[test] + fn only_https_urls_come_back() { + let html = r#" + "#; + let page = preview(html, &base()); + assert_eq!(page.image, None); + assert_eq!(page.icon, None); + let html = r#""#; + assert_eq!(preview(html, &base()).image, None); + } + + #[test] + fn an_https_image_behind_an_http_one_still_comes_back() { + let html = r#" + + "#; + assert_eq!( + preview(html, &base()).image.as_deref(), + Some("https://example.com/a.png") + ); + let html = r#" + "#; + assert_eq!( + preview(html, &base()).image.as_deref(), + Some("https://example.com/b.png") + ); + } + + #[test] + fn only_a_real_boundary_ends_the_head() { + for html in [ + "", + "", + r#""#, + "</head>", + "").ended); + assert!(scan("").ended); + } + + #[test] + fn long_text_is_cut() { + let html = format!("{}", "a".repeat(400)); + let title = preview(&html, &base()).title.unwrap(); + assert_eq!(title.chars().count(), 300); + assert!(title.ends_with('…')); + } + + #[test] + fn broken_markup_ends_quietly() { + for html in [ + "open", + "� a &bogus; b �", &base()) + .title + .as_deref(), + Some("a &bogus; b �") + ); + } + + fn reply(response: Vec) -> (SocketAddr, std::sync::mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sent, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + let n = socket.read(&mut request).unwrap(); + let _ = sent.send(String::from_utf8_lossy(&request[..n]).to_ascii_lowercase()); + let _ = socket.write_all(&response); + }); + (address, received) + } + + fn read(response: Vec) -> Result { + let (address, server) = reply(response); + let url = + reqwest::Url::parse(&format!("http://page.invalid:{}/p", address.port())).unwrap(); + let result = + tauri::async_runtime::block_on(read_from(url, &[address], Duration::from_secs(5))); + let sent = server + .recv_timeout(Duration::from_secs(5)) + .expect("the request reached the pinned address"); + assert!(sent.starts_with("get /p http/1.1"), "{sent}"); + assert!(sent.contains("host: page.invalid"), "{sent}"); + assert!(sent.contains("accept: text/html"), "{sent}"); + assert!(!sent.contains("cookie:"), "{sent}"); + result + } + + fn http(head: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {head}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() + } + + #[test] + fn a_page_comes_back_as_its_preview_only() { + let reply = read(http( + "200 OK\r\nContent-Type: text/html; charset=utf-8\r\nSet-Cookie: a=1", + "Hisecret", + )) + .unwrap(); + assert_eq!(reply.status, 200); + assert_eq!(reply.location, None); + assert_eq!(reply.preview.unwrap().title.as_deref(), Some("Hi")); + } + + #[test] + fn a_redirect_comes_back_for_the_plugin_to_check() { + let reply = read(http( + "301 Moved Permanently\r\nLocation: https://intranet.example/x", + "", + )) + .unwrap(); + assert_eq!(reply.status, 301); + assert_eq!( + reply.location.as_deref(), + Some("https://intranet.example/x") + ); + assert_eq!(reply.preview, None); + } + + #[test] + fn other_types_and_errors_come_back_bare() { + for response in [ + http("200 OK\r\nContent-Type: application/json", "{\"title\":1}"), + http("200 OK", "untyped"), + http( + "404 Not Found\r\nContent-Type: text/html", + "gone", + ), + ] { + let reply = read(response).unwrap(); + assert_eq!((reply.location, reply.preview), (None, None)); + } + } + + #[test] + fn reading_stops_at_the_head_limit() { + let body = format!("{}late", " ".repeat(MAX_HEAD_BYTES)); + let reply = read(http("200 OK\r\nContent-Type: text/html", &body)).unwrap(); + assert_eq!(reply.preview.unwrap().title, None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48760d4c3f..49286782fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -130,8 +130,8 @@ use host_process::{ plugin_host_process_tool_credential, plugin_host_process_write, HostProcesses, }; use host_request::{ - plugin_host_call_context, plugin_host_request, plugin_host_request_bytes, plugin_host_upload, - plugin_host_upload_bytes_once, HostCalls, + plugin_host_call_context, plugin_host_read_page, plugin_host_request, + plugin_host_request_bytes, plugin_host_upload, plugin_host_upload_bytes_once, HostCalls, }; use notifications::{notification_show, Notifications}; #[cfg(target_os = "macos")] @@ -789,6 +789,7 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen plugin_host_call_context, plugin_host_request, plugin_host_request_bytes, + plugin_host_read_page, plugin_host_upload_bytes_once, plugin_host_save_bytes, plugin_host_delete_managed_file, diff --git a/src/app/PluginAccessReview.test.tsx b/src/app/PluginAccessReview.test.tsx index c9f7a5f651..d3b053de74 100644 --- a/src/app/PluginAccessReview.test.tsx +++ b/src/app/PluginAccessReview.test.tsx @@ -225,6 +225,29 @@ it("shows no public image access when it isn't declared", () => { }), ).toBeNull(); }); +const publicPages = (granted: boolean): PluginManifest => ({ + ...manifest(false), + host: { publicPages: granted }, +}); +it("discloses public page reads and that only preview fields come back", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { + name: /Contact any public website for link previews/, + }), + ); + await waitFor(() => + expect(screen.getByText(/returns only its preview fields/)).toBeVisible(), + ); +}); +it("shows no public page access when it isn't declared", () => { + render(); + expect( + screen.queryByRole("button", { + name: /Contact any public website for link previews/, + }), + ).toBeNull(); +}); it.each([ [false, true, "Added"], [true, false, "Removed"], diff --git a/src/app/PluginAccessReview.tsx b/src/app/PluginAccessReview.tsx index df354916e9..e01b2e85aa 100644 --- a/src/app/PluginAccessReview.tsx +++ b/src/app/PluginAccessReview.tsx @@ -129,6 +129,31 @@ function hostDeclarations( }, ] : []), + ...(manifest.host?.publicPages + ? [ + { + key: "publicPages", + signature: + "Public pages: any public HTTPS site · title, description and image only · no cookies", + title: "Public pages:", + label: "Contact any public website for link previews", + hint: "The plugin picks the address, so it can send what it knows to any site in that address. Only a page's title, description, site name, image and icon come back, without your cookies.", + icon: GlobeIcon, + details: ( +

+ Each request goes to port 443 on a public address and follows no + redirect: the plugin sees where a redirect points and decides + whether to ask again. Buzz reads at most the page's head and + returns only its preview fields, never the page itself. The + address check can't recognize internal sites that resolve to + public addresses, such as those reached through a company VPN, + so the plugin has to refuse those itself. Sites see your IP + address. +

+ ), + }, + ] + : []), ...(manifest.host?.federatedIdentity?.operations ?? []).map( (operation) => ({ key: `federated:${operation.method} ${operation.path}`, diff --git a/src/features/host/service.test.ts b/src/features/host/service.test.ts index 2899354641..cf9c61d3a2 100644 --- a/src/features/host/service.test.ts +++ b/src/features/host/service.test.ts @@ -1003,6 +1003,34 @@ it("returns a non-text body view without a clone, accepts empty bodies and never } }); +it("reads a public page's preview through its own command and validates the timeout first", async () => { + const { fiber, host } = await oncePlugin(); + const page = { status: 200, preview: { title: "Example" } }; + vi.mocked(invoke).mockResolvedValueOnce(page); + try { + await expect( + host.readPage?.("https://example.test/", { timeoutMs: 5000 }), + ).resolves.toEqual(page); + expect(vi.mocked(invoke).mock.calls).toEqual([ + [ + "plugin_host_read_page", + { + id: "example.plugin", + revision: "abc", + url: "https://example.test/", + timeoutMs: 5000, + }, + ], + ]); + for (const timeoutMs of [0, 1.5, 30001]) + await expect( + host.readPage?.("https://example.test/", { timeoutMs }), + ).rejects.toThrow("Invalid page request"); + expect(invoke).toHaveBeenCalledTimes(1); + } finally { + await fiber.dispose(); + } +}); it("refuses fallback arrays and corrupted frames before returning any bytes", async () => { const { fiber, host } = await oncePlugin(); const wrongLength = byteFrame(); diff --git a/src/features/host/service.ts b/src/features/host/service.ts index 2f5dc927d6..d7d4dbb9b5 100644 --- a/src/features/host/service.ts +++ b/src/features/host/service.ts @@ -62,6 +62,23 @@ export type HostByteResponse = Readonly<{ headers: Readonly>; body: Uint8Array; }>; +/** What a public page names in its head for a link preview. URLs are + * absolute HTTPS; read the image with `requestBytes` and `host.publicImages`. */ +export type HostPagePreview = Readonly<{ + title?: string; + description?: string; + siteName?: string; + image?: string; + imageAlt?: string; + icon?: string; +}>; +/** A redirect's `location` to check before asking again, a page's `preview`, + * or for anything else its status alone. */ +export type HostPage = Readonly<{ + status: number; + location?: string; + preview?: HostPagePreview; +}>; export type HostSavedBytes = Readonly<{ path: string; size: number; @@ -221,6 +238,12 @@ export interface Host { ) => Promise; /** No automatic replay protection or text/array fallback. */ requestBytes?: (input: HostByteRequest) => Promise; + /** One GET for a public HTTPS page, for a plugin whose manifest declares + * `host.publicPages`. No cookies and no redirects followed. Desktop only. */ + readPage?: ( + url: string, + options?: Readonly<{ timeoutMs?: number }>, + ) => Promise; /** Requires host.managedFiles, not host.files. Keep bytes immutable through * hashing/save. An ambiguous failure must not be automatically retried. */ saveBytes?: ( @@ -705,6 +728,36 @@ export class HostService extends Service implements Host { } } + async readPage( + url: string, + options?: Readonly<{ timeoutMs?: number }>, + ): Promise { + const ctx = this.ctx; + const owner = ctx.pluginOwner; + if (!owner || !isTauri()) + throw new Error("Page reads require an installed desktop plugin"); + const timeoutMs = options?.timeoutMs; + if ( + typeof url !== "string" || + (timeoutMs !== undefined && + (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000)) + ) + throw new Error("Invalid page request"); + const lifetime = byteLifetime(ctx); + try { + const page = await invoke("plugin_host_read_page", { + id: owner.id, + revision: owner.revision, + url, + timeoutMs, + }).catch(rethrow); + lifetime.active(); + return page; + } finally { + lifetime.release(); + } + } + async saveBytes( bytes: Uint8Array, { sha256 }: Readonly<{ sha256: string }>, diff --git a/src/plugins/api.ts b/src/plugins/api.ts index f148ba0c13..3c6647bd8c 100644 --- a/src/plugins/api.ts +++ b/src/plugins/api.ts @@ -76,6 +76,10 @@ export type PluginManifest = Readonly<{ * address, one hop at a time with no cookies, outside `networkOrigins`. * Redirects and pages come back without a body. */ publicImages?: boolean; + /** `host.readPage` may read the title, description, site name, image + * and icon a public HTTPS page names in its head, one hop at a time with + * no cookies. The page itself never comes back. */ + publicPages?: boolean; }>; }>; // Module evaluation must be pure. apply owns resources through ctx.effect. diff --git a/src/plugins/author.test.mjs b/src/plugins/author.test.mjs index 5f034fd7a7..a06aec2283 100644 --- a/src/plugins/author.test.mjs +++ b/src/plugins/author.test.mjs @@ -41,6 +41,7 @@ export const manifest: PluginManifest = { networkOrigins: ["https://api.example.com"], managedFiles: true, publicImages: true, + publicPages: true, }, }; const ExampleSidebar = ({ target, children }: PageSidebarProps) => {