diff --git a/CHANGELOG.md b/CHANGELOG.md index 1900ead..0ac81fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### New +- **Link previews in chats (Chat+).** X's web chat shows a link as a bare card with only the site's name. wayBetter shows the page's title, text and picture under the message, and for YouTube the video, playable right in the chat. Only the link is sent to its site (no cookies, never the message or who sent it), and nothing is saved. On by default; Chat+ → Link previews turns it off. + ## 1.0.2 ### New diff --git a/apps/desktop/locales/en.json b/apps/desktop/locales/en.json index 4efac7c..549f578 100644 --- a/apps/desktop/locales/en.json +++ b/apps/desktop/locales/en.json @@ -751,6 +751,10 @@ "plugin.chat-plus.bookmarks.importBad": "This is not an export of starred messages.", "plugin.chat-plus.bookmarks.imported": "{count, plural, =0 {Nothing new to import} one {# starred message imported} other {# starred messages imported}}", "plugin.chat-plus.bookmarks.close": "Close", + "plugin.chat-plus.feature.linkPreviews.label": "Link previews", + "plugin.chat-plus.feature.linkPreviews.description": "Shows a preview under a link in a chat: the page's title, text and picture, and for YouTube the video, playable in place. To make one, the app asks the link's site for its preview itself: only the link is sent, without cookies, never the message or who sent it. Nothing is saved.", + "plugin.chat-plus.linkPreviews.play": "Play the video here", + "plugin.chat-plus.linkPreviews.open": "Open the link", "plugin.chat-plus.feature.searchLoaded.label": "Find in conversation", "plugin.chat-plus.feature.searchLoaded.description": "Ctrl+Shift+F in a chat searches the messages X has loaded so far and jumps to each match. It can load older messages while it is open.", "plugin.chat-plus.searchLoaded.title": "Find in conversation", diff --git a/apps/desktop/locales/languages.json b/apps/desktop/locales/languages.json index 233f0d7..4b175cc 100644 --- a/apps/desktop/locales/languages.json +++ b/apps/desktop/locales/languages.json @@ -17,7 +17,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ar-x-fm", @@ -28,7 +28,7 @@ "tier": "community", "reviewers": [], "fallback": "ar", - "coverage": 1 + "coverage": 0.996 }, { "code": "bn", @@ -38,7 +38,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "eu", @@ -48,7 +48,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "en-GB", @@ -69,7 +69,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ca", @@ -79,7 +79,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "zh-Hans", @@ -89,7 +89,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "zh-Hant", @@ -99,7 +99,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "hr", @@ -109,7 +109,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "cs", @@ -119,7 +119,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "da", @@ -129,7 +129,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "nl", @@ -139,7 +139,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "fil", @@ -149,7 +149,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "fi", @@ -159,7 +159,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "fr", @@ -169,7 +169,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "gl", @@ -179,7 +179,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "de", @@ -189,7 +189,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "el", @@ -199,7 +199,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "gu", @@ -209,7 +209,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ha", @@ -219,7 +219,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "he", @@ -229,7 +229,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "hi", @@ -239,7 +239,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "hu", @@ -249,7 +249,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ig", @@ -259,7 +259,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "id", @@ -269,7 +269,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ga", @@ -279,7 +279,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "it", @@ -289,7 +289,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ja", @@ -299,7 +299,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "kn", @@ -309,7 +309,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ko", @@ -319,7 +319,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ms", @@ -329,7 +329,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "mr", @@ -339,7 +339,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "nb", @@ -349,7 +349,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "fa", @@ -359,7 +359,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "pl", @@ -369,7 +369,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "pt", @@ -379,7 +379,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ro", @@ -389,7 +389,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ru", @@ -399,7 +399,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "sr", @@ -409,7 +409,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "sk", @@ -419,7 +419,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "es", @@ -429,7 +429,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "sv", @@ -439,7 +439,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ta", @@ -449,7 +449,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "th", @@ -459,7 +459,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "tr", @@ -479,7 +479,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "ur", @@ -489,7 +489,7 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "vi", @@ -499,7 +499,7 @@ "status": "machine", "tier": "supported", "reviewers": [], - "coverage": 1 + "coverage": 0.996 }, { "code": "yo", @@ -509,6 +509,6 @@ "status": "machine", "tier": "community", "reviewers": [], - "coverage": 1 + "coverage": 0.996 } ] diff --git a/apps/desktop/locales/tr.json b/apps/desktop/locales/tr.json index f2e3ef9..fc1912a 100644 --- a/apps/desktop/locales/tr.json +++ b/apps/desktop/locales/tr.json @@ -800,6 +800,10 @@ "plugin.chat-plus.personFilter.groupOnly": "Önce bir grup sohbeti aç", "plugin.chat-plus.personFilter.nobody": "Henüz seçilecek kimse yok: hiç mesaj yüklenmedi.", "plugin.chat-plus.personFilter.hint": "Şimdiye kadar yüklenen mesajları yazan kişiler. Daha fazlası için yukarı kaydır.", + "plugin.chat-plus.feature.linkPreviews.label": "Link önizlemeleri", + "plugin.chat-plus.feature.linkPreviews.description": "Sohbetteki bir linkin altında önizlemesini gösterir: sayfanın başlığı, metni ve görseli; YouTube'da ise yerinde oynatılabilen video. Önizleme için uygulama linkin sitesine kendisi sorar: yalnızca link gider, çerezsiz; mesaj ya da gönderen asla gitmez. Hiçbir şey kaydedilmez.", + "plugin.chat-plus.linkPreviews.play": "Videoyu burada oynat", + "plugin.chat-plus.linkPreviews.open": "Linki aç", "plugin.chat-plus.feature.keywords.label": "Sohbetlerde anahtar kelimeler", "plugin.chat-plus.feature.keywords.description": "Kelimelerini içeren sohbet mesajlarını vurgular, sessize aldığın kelimeleri içeren mesajları katlar. Katlanmış bir mesaj tıklayınca açılır.", "plugin.chat-plus.keywords.highlight": "Vurgulanacak kelimeler", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d8c85be..e10f555 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -296,7 +296,9 @@ dependencies = [ "base64 0.22.1", "chacha20poly1305", "chrono", + "reqwest", "rusqlite", + "rustls", "serde", "serde_json", "sha2", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index b8b5ad4..9d3cafd 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -25,6 +25,8 @@ desktop = [ "waybetter-host/app", "waybetter-host/net", "waybetter-host/macos-private-api", + "dep:reqwest", + "dep:rustls", ] [build-dependencies] @@ -52,6 +54,9 @@ url = "2" tauri-plugin-deep-link = { version = "2", optional = true } # Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin). tauri-plugin-dialog = { version = "2", optional = true } +# Chat+ link previews (src/link_preview.rs): the same client the host's store uses, no cookies. +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"], optional = true } +rustls = { version = "0.23", default-features = false, features = ["ring"], optional = true } sha2 = "0.10" base64 = "0.22" # Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase, diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index c9fa9c7..215c735 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,25 +1,26 @@ -fn main() { - #[cfg(feature = "desktop")] - tauri_build::try_build(tauri_build::Attributes::new().app_manifest( - // X's own commands; `manifest` adds the ones the wayBetter host serves for every app - // (`waybetter_host::commands`: tab layout and actions, shell raise, profiles, language, - // the plugins folder and custom CSS, the tweak flags, the shell's settings). - tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[ - "capture_draft", - "column_configure", - "tab_report", - "drafts_picker_open", - "tab_insert_text", - "drafts_popover_dismiss", - "drafts_hint", - "popover_state", - "popover_drafts", - "popover_insert", - "popover_close", - "popover_open_vault", - "keyword_alert", - "tab_attach_image", - ])), - )) - .expect("Tauri build configuration must be valid"); -} +fn main() { + #[cfg(feature = "desktop")] + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + // X's own commands; `manifest` adds the ones the wayBetter host serves for every app + // (`waybetter_host::commands`: tab layout and actions, shell raise, profiles, language, + // the plugins folder and custom CSS, the tweak flags, the shell's settings). + tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[ + "capture_draft", + "column_configure", + "tab_report", + "drafts_picker_open", + "tab_insert_text", + "drafts_popover_dismiss", + "drafts_hint", + "popover_state", + "popover_drafts", + "popover_insert", + "popover_close", + "popover_open_vault", + "keyword_alert", + "link_preview", + "tab_attach_image", + ])), + )) + .expect("Tauri build configuration must be valid"); +} diff --git a/apps/desktop/src-tauri/capabilities/x-capture.json b/apps/desktop/src-tauri/capabilities/x-capture.json index 637ce98..102a2c7 100644 --- a/apps/desktop/src-tauri/capabilities/x-capture.json +++ b/apps/desktop/src-tauri/capabilities/x-capture.json @@ -1,10 +1,10 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "x-capture", - "description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids) and reporting keyword matches about itself. No reads of the local vault, no shell, filesystem or generic APIs.", - "webviews": ["tab-*"], - "local": false, - "platforms": ["macOS", "windows", "linux"], - "remote": { "urls": ["https://x.com/*"] }, - "permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"] -} +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "x-capture", + "description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids), reporting keyword matches about itself and, on its own chat page only, asking for the preview of one link it shows (native fetch: public https hosts only, no cookies, size caps; src/link_preview.rs). No reads of the local vault, no shell, filesystem or generic APIs.", + "webviews": ["tab-*"], + "local": false, + "platforms": ["macOS", "windows", "linux"], + "remote": { "urls": ["https://x.com/*"] }, + "permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"] +} diff --git a/apps/desktop/src-tauri/src/areas.rs b/apps/desktop/src-tauri/src/areas.rs index 099f77f..bfdad37 100644 --- a/apps/desktop/src-tauri/src/areas.rs +++ b/apps/desktop/src-tauri/src/areas.rs @@ -77,6 +77,7 @@ pub const FLAGS: &[&str] = &[ "chat.searchLoaded", "chat.pins", "chat.personFilter", + "chat.linkPreviews", "chat.keywords", "mediaDownload", "rememberVideoVolume", diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index 2c1105a..ae1c826 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -233,6 +233,19 @@ async fn keyword_alert(webview: Webview, state: State<'_, AppState>, hit: Keywor Ok(()) } +/// Chat+ link previews (owner-approved network use, 2026-10-10): an X tab on its own chat page asks +/// for the preview of one link it shows. Only the link reaches native code; the fetch rules +/// (public https hosts, no cookies, pinned addresses, size caps, memory-only cache) are in link_preview.rs. +#[tauri::command] +async fn link_preview(webview: Webview, url: String) -> Result { + policy::require_capture(webview.label(), &webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?)?; + let page = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?; + if !crate::link_preview::is_chat_page(page.path()) { + return Err("FORBIDDEN: link previews are for X's chat pages only".into()); + } + crate::link_preview::preview(&url).await +} + /// Tells an X tab whether its left navigation repeats one already on screen; the runtime hides it /// while the `singleSplitNav` feature is on. Queued in `__btPane` until the runtime exists. fn pane_script(nav_redundant: bool) -> String { @@ -578,6 +591,7 @@ pub fn run() { desktop_popover::popover_close, desktop_popover::popover_open_vault, keyword_alert, + link_preview, crate::desktop_media::tab_attach_image, ], ); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d26e430..14d63de 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ pub mod devmode; pub mod distribution; pub mod gfonts; pub mod hotfix; +pub mod link_preview; pub mod media; pub mod model; pub mod palette_commands; diff --git a/apps/desktop/src-tauri/src/link_preview.rs b/apps/desktop/src-tauri/src/link_preview.rs new file mode 100644 index 0000000..6f657f0 --- /dev/null +++ b/apps/desktop/src-tauri/src/link_preview.rs @@ -0,0 +1,607 @@ +//! Chat+ link previews (owner-approved network use, 2026-10-10): X's web chat shows a link as a bare +//! card (an icon and the domain), so an X tab on a chat page may ask native code for the preview of +//! ONE link it shows. Native code fetches that link's page head (or YouTube's oEmbed) itself: +//! +//! - https only, no user info, default port, a domain name (no IP literal), not X's own hosts; +//! - every address the name resolves to must be public (no loopback, private, link-local, CGNAT, +//! multicast, documentation or unique-local ranges), and the connection is pinned to those +//! addresses, so a DNS answer cannot point the request into the owner's network; +//! - no cookies (no cookie store), no referrer, a plain user agent naming the app; +//! - redirects followed by hand, at most [`MAX_REDIRECTS`], each hop checked again; +//! - only an HTML head is read, at most [`MAX_PAGE_BYTES`]; an image at most [`MAX_IMAGE_BYTES`], +//! PNG/JPEG/GIF/WebP by its magic bytes only, handed back as a `data:` URL; +//! - the request carries the link and nothing else: no conversation, sender or message text. +//! +//! Results live in memory only (a bounded cache) and are never written anywhere. + +use serde::Serialize; +use std::net::IpAddr; +use url::Url; + +pub const MAX_URL_LEN: usize = 2_048; +pub const MAX_REDIRECTS: usize = 4; +pub const MAX_PAGE_BYTES: usize = 512 * 1024; +pub const MAX_IMAGE_BYTES: usize = 1_536 * 1024; +pub const MAX_TITLE: usize = 200; +pub const MAX_DESCRIPTION: usize = 300; +pub const MAX_SITE: usize = 80; +pub const USER_AGENT: &str = "Mozilla/5.0 (compatible; wayBetter-LinkPreview/1.0; +https://waybetter.fyi/x/)"; + +/// What a chat tab gets back: plain text and, for a page, an image as a `data:` URL. A YouTube link +/// also carries its video id (the page offers YouTube's own player, a www.youtube.com frame X allows). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinkPreview { + pub url: String, + pub site: String, + pub title: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub youtube: Option, +} + +/// X's chat pages (`/i/chat`, `/messages`): the only pages that may ask for a preview. +pub fn is_chat_page(path: &str) -> bool { + ["/i/chat", "/messages"].iter().any(|root| path == *root || path.starts_with(&format!("{root}/"))) +} + +/// X's own hosts: their links are X's to show, never fetched from here. +fn is_x_host(host: &str) -> bool { + ["x.com", "twitter.com", "t.co", "twimg.com", "x.co"].iter().any(|own| host == *own || host.ends_with(&format!(".{own}"))) +} + +/// A link a chat tab may ask about: https, a domain name, default port, no user info, not X. +pub fn check_link(value: &str) -> Result { + if value.is_empty() || value.len() > MAX_URL_LEN || value.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("VALIDATION: invalid link".into()); + } + let url = Url::parse(value).map_err(|_| "VALIDATION: invalid link")?; + check_url(&url)?; + Ok(url) +} + +fn check_url(url: &Url) -> Result<(), String> { + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return Err("VALIDATION: only plain https links".into()); + } + if url.port().is_some_and(|port| port != 443) { + return Err("VALIDATION: only the default port".into()); + } + let host = match url.host() { + Some(url::Host::Domain(host)) => host.to_ascii_lowercase(), + _ => return Err("VALIDATION: the link must name a host".into()), + }; + if !host.contains('.') || host.ends_with(".local") || host.ends_with(".localhost") || host.ends_with(".internal") || host == "localhost" { + return Err("VALIDATION: not a public host".into()); + } + if is_x_host(&host) { + return Err("UNSUPPORTED: X's own links".into()); + } + Ok(()) +} + +/// Public unicast only: anything that could reach the owner's machine or network is refused. +pub fn is_public(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let [a, b, c, _] = v4.octets(); + !(v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_multicast() + || v4.is_unspecified() + || v4.is_documentation() + || a == 0 + || (a == 100 && (64..=127).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 198 && (18..=19).contains(&b)) + || a >= 240) + } + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return is_public(IpAddr::V4(v4)); + } + let first = v6.segments()[0]; + !(v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || first == 0x2001 && v6.segments()[1] == 0x0db8 + || first == 0x0064 && v6.segments()[1] == 0xff9b) + } + } +} + +/// The video id of a YouTube watch, short, shorts or embed link. +pub fn youtube_id(url: &Url) -> Option { + let host = url.host_str()?.trim_start_matches("www.").trim_start_matches("m.").to_ascii_lowercase(); + let id = match host.as_str() { + "youtu.be" => url.path_segments()?.next()?.to_string(), + "youtube.com" | "music.youtube.com" => { + let mut segments = url.path_segments()?; + match segments.next()? { + "watch" => url.query_pairs().find(|(k, _)| k == "v")?.1.into_owned(), + "shorts" | "embed" | "live" => segments.next()?.to_string(), + _ => return None, + } + } + _ => return None, + }; + (id.len() == 11 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then_some(id) +} + +/// Plain text: entities decoded, control characters and runs of white space collapsed, capped. +pub fn clean(value: &str, max: usize) -> String { + let decoded = decode_entities(value); + let mut out = String::new(); + let mut space = false; + for c in decoded.chars() { + if c.is_whitespace() || c.is_control() { + space = !out.is_empty(); + continue; + } + if matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') { + continue; + } + if space { + out.push(' '); + space = false; + } + out.push(c); + } + if out.chars().count() > max { + out = out.chars().take(max.saturating_sub(1)).collect::().trim_end().to_string() + "\u{2026}"; + } + out +} + +fn decode_entities(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(at) = rest.find('&') { + out.push_str(&rest[..at]); + rest = &rest[at..]; + let end = rest[..rest.len().min(12)].find(';'); + let decoded = end.and_then(|end| { + let name = &rest[1..end]; + let c = match name { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" | "#39" => Some('\''), + "nbsp" => Some(' '), + _ if name.starts_with("#x") || name.starts_with("#X") => u32::from_str_radix(&name[2..], 16).ok().and_then(char::from_u32), + _ if name.starts_with('#') => name[1..].parse().ok().and_then(char::from_u32), + _ => None, + }?; + Some((c, end + 1)) + }); + match decoded { + Some((c, len)) => { + out.push(c); + rest = &rest[len..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +/// The attributes of one tag (``), names lowercased. +fn attributes(tag: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let bytes = tag.as_bytes(); + let mut i = 0; + while i < bytes.len() { + while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/') { + i += 1; + } + let start = i; + while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'=' && bytes[i] != b'>' { + i += 1; + } + if start == i { + i += 1; + continue; + } + let name = tag[start..i].to_ascii_lowercase(); + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let mut value = String::new(); + if i < bytes.len() && bytes[i] == b'=' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') { + let quote = bytes[i]; + i += 1; + let from = i; + while i < bytes.len() && bytes[i] != quote { + i += 1; + } + value = tag[from..i].to_string(); + i += 1; + } else { + let from = i; + while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' { + i += 1; + } + value = tag[from..i].to_string(); + } + } + out.push((name, value)); + } + out +} + +/// What a page's head says about itself: Open Graph first, then Twitter cards, then `` and +/// the description meta. `image` is resolved against `base` and checked like a link. +#[derive(Debug, Default, PartialEq)] +pub struct PageMeta { + pub title: String, + pub description: String, + pub site: String, + pub image: Option<Url>, +} + +pub fn parse_head(html: &str, base: &Url) -> PageMeta { + let lower = html.to_ascii_lowercase(); + let head_end = lower.find("</head").unwrap_or(lower.len()); + let (html, lower) = (&html[..head_end], &lower[..head_end]); + let mut found: Vec<(String, String)> = Vec::new(); + let mut at = 0; + while let Some(start) = lower[at..].find("<meta").map(|i| at + i) { + let Some(end) = lower[start..].find('>').map(|i| start + i) else { break }; + let attrs = attributes(&html[start + 5..end]); + let key = attrs.iter().find(|(n, _)| n == "property" || n == "name").map(|(_, v)| v.to_ascii_lowercase()); + let content = attrs.iter().find(|(n, _)| n == "content").map(|(_, v)| v.clone()); + if let (Some(key), Some(content)) = (key, content) { + found.push((key, content)); + } + at = end; + } + let get = |keys: &[&str]| keys.iter().find_map(|key| found.iter().find(|(k, v)| k == key && !v.trim().is_empty()).map(|(_, v)| v.clone())); + let title_tag = lower.find("<title").and_then(|start| { + let open = start + lower[start..].find('>')? + 1; + let close = open + lower[open..].find("</title")?; + Some(html[open..close].to_string()) + }); + let title = get(&["og:title", "twitter:title"]).or(title_tag).unwrap_or_default(); + let description = get(&["og:description", "twitter:description", "description"]).unwrap_or_default(); + let site = get(&["og:site_name", "application-name"]).unwrap_or_default(); + let image = get(&["og:image:secure_url", "og:image", "og:image:url", "twitter:image", "twitter:image:src"]) + .and_then(|value| base.join(decode_entities(value.trim()).as_str()).ok()) + .filter(|url| check_url(url).is_ok()); + PageMeta { + title: clean(&title, MAX_TITLE), + description: clean(&description, MAX_DESCRIPTION), + site: clean(&site, MAX_SITE), + image, + } +} + +/// The image type by its first bytes; anything else (SVG included) is refused. +pub fn image_mime(bytes: &[u8]) -> Option<&'static str> { + match bytes { + [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, ..] => Some("image/png"), + [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"), + [b'G', b'I', b'F', b'8', b'7' | b'9', b'a', ..] => Some("image/gif"), + [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Some("image/webp"), + _ => None, + } +} + +/// The host as shown under a preview when the page names no site (`www.` dropped). +pub fn display_host(url: &Url) -> String { + clean(url.host_str().unwrap_or_default().trim_start_matches("www."), MAX_SITE) +} + +#[cfg(feature = "desktop")] +pub use fetch::preview; + +#[cfg(feature = "desktop")] +mod fetch { + use super::*; + use base64::Engine; + use std::collections::HashMap; + use std::net::{SocketAddr, ToSocketAddrs}; + use std::sync::{Mutex, OnceLock}; + use std::time::{Duration, Instant}; + + const CACHE_ENTRIES: usize = 256; + const CACHE_TTL: Duration = Duration::from_secs(30 * 60); + /// At most this many fresh fetches per minute for the whole app (cached answers are free). + const PER_MINUTE: usize = 30; + + type Cached = (Instant, Result<LinkPreview, String>); + + fn cache() -> &'static Mutex<HashMap<String, Cached>> { + static CACHE: OnceLock<Mutex<HashMap<String, Cached>>> = OnceLock::new(); + CACHE.get_or_init(Default::default) + } + + fn allow_fetch() -> bool { + static RECENT: OnceLock<Mutex<Vec<Instant>>> = OnceLock::new(); + let mut recent = RECENT.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner()); + let now = Instant::now(); + recent.retain(|at| now.duration_since(*at) < Duration::from_secs(60)); + if recent.len() >= PER_MINUTE { + return false; + } + recent.push(now); + true + } + + /// The preview of one link: from the cache, else fetched under the rules above. + pub async fn preview(link: &str) -> Result<LinkPreview, String> { + let url = check_link(link)?; + let key = url.as_str().to_string(); + if let Some((at, result)) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if at.elapsed() < CACHE_TTL { + return result.clone(); + } + } + if !allow_fetch() { + return Err("BUSY: too many previews, try again shortly".into()); + } + let result = match youtube_id(&url) { + Some(id) => youtube(&url, id).await, + None => page(&url).await, + }; + let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner()); + if cache.len() >= CACHE_ENTRIES { + cache.retain(|_, (at, _)| at.elapsed() < CACHE_TTL); + if cache.len() >= CACHE_ENTRIES { + cache.clear(); + } + } + cache.insert(key, (Instant::now(), result.clone())); + result + } + + /// Resolves the host off the async runtime and keeps only public addresses (all must be public). + async fn public_addrs(url: &Url) -> Result<(String, Vec<SocketAddr>), String> { + let host = url.host_str().ok_or("VALIDATION: no host")?.to_string(); + let lookup = host.clone(); + let addrs: Vec<SocketAddr> = tauri::async_runtime::spawn_blocking(move || (lookup.as_str(), 443).to_socket_addrs().map(|a| a.collect::<Vec<_>>())) + .await + .map_err(|_| "NETWORK: lookup failed")? + .map_err(|_| "NETWORK: the host does not resolve")?; + if addrs.is_empty() || !addrs.iter().all(|addr| is_public(addr.ip())) { + return Err("FORBIDDEN: the host is not on the public internet".into()); + } + Ok((host, addrs)) + } + + fn client(host: &str, addrs: &[SocketAddr]) -> Result<reqwest::Client, String> { + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + // No cookie store: nothing is sent or kept. Redirects are followed by hand (each hop checked). + reqwest::Client::builder() + .user_agent(USER_AGENT) + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .https_only(true) + .connect_timeout(Duration::from_secs(6)) + .timeout(Duration::from_secs(12)) + .resolve_to_addrs(host, addrs) + .build() + .map_err(|e| format!("NETWORK: {e}")) + } + + /// GET with hand-followed redirects; returns the final URL and up to `max` bytes of the body + /// (`stop` may end the read early, e.g. once the page head is complete). + async fn get(start: &Url, accept: &str, max: usize, stop: fn(&[u8]) -> bool) -> Result<(Url, String, Vec<u8>), String> { + let mut url = start.clone(); + for _ in 0..=MAX_REDIRECTS { + check_url(&url)?; + let (host, addrs) = public_addrs(&url).await?; + let mut response = client(&host, &addrs)? + .get(url.clone()) + .header(reqwest::header::ACCEPT, accept) + .header(reqwest::header::ACCEPT_LANGUAGE, "en;q=0.9, *;q=0.5") + .send() + .await + .map_err(|e| format!("NETWORK: {}", e.without_url()))?; + let status = response.status(); + if status.is_redirection() { + let location = response.headers().get(reqwest::header::LOCATION).and_then(|v| v.to_str().ok()).ok_or("NETWORK: redirect without a location")?; + url = url.join(location).map_err(|_| "NETWORK: invalid redirect")?; + continue; + } + if !status.is_success() { + return Err(format!("NETWORK: the site answered {}", status.as_u16())); + } + let mime = response.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase(); + if response.content_length().is_some_and(|len| len as usize > max) && !accept.starts_with("text/html") { + return Err("VALIDATION: too large".into()); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| format!("NETWORK: {}", e.without_url()))? { + let room = max.saturating_sub(body.len()); + body.extend_from_slice(&chunk[..chunk.len().min(room)]); + if body.len() >= max || stop(&body) { + break; + } + } + return Ok((url, mime, body)); + } + Err("NETWORK: too many redirects".into()) + } + + fn head_complete(body: &[u8]) -> bool { + let tail = &body[body.len().saturating_sub(16 * 1024)..]; + tail.windows(7).any(|w| w.eq_ignore_ascii_case(b"</head>")) + } + + fn never(_: &[u8]) -> bool { + false + } + + async fn page(url: &Url) -> Result<LinkPreview, String> { + let (final_url, mime, body) = get(url, "text/html,application/xhtml+xml;q=0.9", MAX_PAGE_BYTES, head_complete).await?; + if !(mime.starts_with("text/html") || mime.starts_with("application/xhtml")) { + return Err("UNSUPPORTED: not a web page".into()); + } + let html = String::from_utf8_lossy(&body); + let meta = parse_head(&html, &final_url); + if meta.title.is_empty() && meta.image.is_none() { + return Err("UNSUPPORTED: the page has no preview".into()); + } + let image = match &meta.image { + Some(image) => fetch_image(image).await.ok(), + None => None, + }; + Ok(LinkPreview { + url: url.to_string(), + site: if meta.site.is_empty() { display_host(&final_url) } else { meta.site }, + title: meta.title, + description: meta.description, + image, + youtube: None, + }) + } + + async fn fetch_image(url: &Url) -> Result<String, String> { + let (_, _, bytes) = get(url, "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.8", MAX_IMAGE_BYTES, never).await?; + if bytes.len() >= MAX_IMAGE_BYTES { + return Err("VALIDATION: image too large".into()); + } + let mime = image_mime(&bytes).ok_or("UNSUPPORTED: not an image")?; + Ok(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(&bytes))) + } + + /// YouTube: its oEmbed answer (title, channel) and the video's thumbnail; the player is YouTube's + /// own www.youtube.com, which X's page allows as a frame, loaded only when the user presses play. + async fn youtube(url: &Url, id: String) -> Result<LinkPreview, String> { + let mut oembed = Url::parse("https://www.youtube.com/oembed?format=json").expect("static URL"); + oembed.query_pairs_mut().append_pair("url", &format!("https://www.youtube.com/watch?v={id}")); + let (_, _, body) = get(&oembed, "application/json", 64 * 1024, never).await?; + let value: serde_json::Value = serde_json::from_slice(&body).map_err(|_| "UNSUPPORTED: no video data")?; + let text = |key: &str, max: usize| clean(value.get(key).and_then(|v| v.as_str()).unwrap_or(""), max); + // The thumbnail comes as data too: X's chat page does not reliably load it from i.ytimg.com. + let thumbnail = Url::parse(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg")).expect("checked id"); + Ok(LinkPreview { + url: url.to_string(), + site: "YouTube".into(), + title: text("title", MAX_TITLE), + description: text("author_name", MAX_SITE), + image: fetch_image(&thumbnail).await.ok(), + youtube: Some(id), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_plain_public_https_links() { + for ok in ["https://example.com/a?b=c", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://sub.example.co.uk:443/"] { + assert!(check_link(ok).is_ok(), "{ok}"); + } + for bad in [ + "http://example.com/", + "https://user:pw@example.com/", + "https://example.com:8443/", + "https://127.0.0.1/", + "https://[::1]/", + "https://192.168.0.1/", + "https://localhost/", + "https://printer.local/", + "https://intranet/", + "https://x.com/someone/status/1", + "https://pbs.twimg.com/media/a.jpg", + "https://t.co/abc", + "file:///etc/passwd", + "javascript:alert(1)", + "https://exa mple.com/", + "", + ] { + assert!(check_link(bad).is_err(), "{bad}"); + } + assert!(check_link(&format!("https://example.com/{}", "a".repeat(MAX_URL_LEN))).is_err()); + } + + #[test] + fn private_and_special_addresses_are_not_public() { + for ip in ["127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.0.192", "169.254.1.1", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "::1", "fe80::1", "fd00::1", "::ffff:192.168.0.1", "2001:db8::1", "64:ff9b::a00:1"] { + assert!(!is_public(ip.parse().unwrap()), "{ip}"); + } + for ip in ["1.1.1.1", "142.250.185.78", "2606:4700:4700::1111"] { + assert!(is_public(ip.parse().unwrap()), "{ip}"); + } + } + + #[test] + fn chat_pages_only() { + for ok in ["/i/chat", "/i/chat/1-2", "/messages", "/messages/1-2"] { + assert!(is_chat_page(ok), "{ok}"); + } + for bad in ["/home", "/i/chatter", "/messagesx", "/someone/status/1", "/"] { + assert!(!is_chat_page(bad), "{bad}"); + } + } + + #[test] + fn youtube_ids() { + let id = |s: &str| youtube_id(&Url::parse(s).unwrap()); + assert_eq!(id("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"), Some("dQw4w9WgXcQ".into())); + assert_eq!(id("https://youtu.be/dQw4w9WgXcQ?si=x"), Some("dQw4w9WgXcQ".into())); + assert_eq!(id("https://m.youtube.com/shorts/dQw4w9WgXcQ"), Some("dQw4w9WgXcQ".into())); + assert_eq!(id("https://www.youtube.com/embed/dQw4w9WgXcQ"), Some("dQw4w9WgXcQ".into())); + assert_eq!(id("https://www.youtube.com/watch?v=bad\"id"), None); + assert_eq!(id("https://www.youtube.com/@channel"), None); + assert_eq!(id("https://example.com/watch?v=dQw4w9WgXcQ"), None); + } + + #[test] + fn reads_a_page_head() { + let base = Url::parse("https://fishies.lol/play/").unwrap(); + let html = r#"<!doctype html><html><head><title>Fallback + + + + + "#; + let meta = parse_head(html, &base); + assert_eq!(meta.title, "Fishies & friends"); + assert_eq!(meta.description, "The social aquarium"); + assert_eq!(meta.site, "Fishies"); + assert_eq!(meta.image.unwrap().as_str(), "https://fishies.lol/img/card.png"); + let bare = parse_head("\n Only a title \u{202e}", &base); + assert_eq!(bare.title, "Only a title"); + assert_eq!(bare.image, None, "an image off the link rules is dropped"); + assert_eq!(parse_head("", &base).image, None); + } + + #[test] + fn text_is_plain_and_capped() { + assert_eq!(clean("a<b> AB &unknown; &", 50), "a AB &unknown; &"); + assert_eq!(clean(&"x".repeat(500), 10).chars().count(), 10); + assert_eq!(clean("\u{0}\u{7}one\ttwo\n", 50), "one two"); + } + + #[test] + fn images_by_magic_bytes_only() { + assert_eq!(image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png")); + assert_eq!(image_mime(b"\xff\xd8\xff\xe0"), Some("image/jpeg")); + assert_eq!(image_mime(b"GIF89a"), Some("image/gif")); + assert_eq!(image_mime(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); + assert_eq!(image_mime(b""), None); + } +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 9a50854..4213d2c 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -1,430 +1,434 @@ -use url::Url; -use better_twttr::{policy::*, site::SITE, theme::{x_default, XTheme, X_THEMES}}; - -#[test] -fn exact_origin_and_window_scope_is_enforced() { - let x = Url::parse("https://x.com/home").unwrap(); - assert!(require_capture("tab-1", &x).is_ok()); - assert!(require_capture("shell", &x).is_err()); - assert!(require_local( - "tab-1", - &Url::parse("tauri://localhost/index.html").unwrap(), - false - ) - .is_err()); - for value in [ - "https://evil.x.com/home", - "http://x.com/home", - "https://x.com:8443/home", - "https://x.com.evil.test/home", - "https://x.com@evil.test/home", - "https://evil@x.com/home", - "file:///tmp/index.html", - "https://twitter.com/home", - ] { - assert!( - require_capture("tab-1", &Url::parse(value).unwrap()).is_err(), - "accepted {value}" - ); - } -} - -#[test] -fn local_origin_has_no_production_localhost_backdoor() { - for value in [ - "tauri://localhost/index.html", - "http://tauri.localhost/index.html", - "https://tauri.localhost/index.html", - ] { - assert!(require_local("shell", &Url::parse(value).unwrap(), false).is_ok()); - } - assert!(require_local("shell", &Url::parse("http://127.0.0.1:1420/").unwrap(), true).is_ok()); - for value in [ - "https://x.com/", - "http://127.0.0.1:1420/", - "http://localhost:1420/", - "https://tauri.localhost.evil.test/", - "tauri://evil/", - ] { - assert!( - require_local("shell", &Url::parse(value).unwrap(), false).is_err(), - "accepted {value}" - ); - } -} - -#[test] -fn navigation_allows_only_x_and_specific_legacy_login_routes() { - for value in [ - "https://x.com/home", - "https://twitter.com/login", - "https://twitter.com/i/flow/login", - "https://twitter.com/i/flow/login/step", - ] { - assert!(x_navigation_allowed(&Url::parse(value).unwrap())); - } - for value in [ - "https://twitter.com/home", - "https://t.co/test", - "https://accounts.google.com/", - "javascript:alert(1)", - "mailto:test@example.com", - "https://twitter.com/login/evil", - ] { - assert!( - !x_navigation_allowed(&Url::parse(value).unwrap()), - "accepted {value}" - ); - } -} - -#[test] -fn remote_capabilities_cannot_reach_the_vault() { - let read = |json: &str| serde_json::from_str::(json).unwrap(); - let x = read(include_str!("../capabilities/x-capture.json")); - // Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question. - assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"])); - for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] { - assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}"); - } - - // The drafts popover is local-only and gets nothing beyond its own five commands. - let popover = read(include_str!("../capabilities/drafts-popover.json")); - assert_eq!(popover["webviews"], serde_json::json!(["popover-*"])); - assert_eq!(popover["local"], true); - assert!(popover.get("remote").is_none()); - assert_eq!(popover["permissions"], serde_json::json!(["allow-popover-state", "allow-popover-drafts", "allow-popover-insert", "allow-popover-close", "allow-popover-open-vault"])); - assert_eq!(x["webviews"], serde_json::json!(["tab-*"])); - assert_eq!(x["remote"]["urls"], serde_json::json!(["https://x.com/*"])); - assert_eq!(x["local"], false); - // Linux joined with the same permissions (owner request, 2026-09-28): platforms only, no new grants. - assert_eq!(x["platforms"], serde_json::json!(["macOS", "windows", "linux"])); - - let tabs = read(include_str!("../capabilities/tab-chrome.json")); - assert_eq!(tabs["permissions"], serde_json::json!(["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"])); - assert_eq!(tabs["webviews"], serde_json::json!(["tab-*"])); - assert_eq!(tabs["local"], false); - - let local = read(include_str!("../capabilities/workspace-local.json")); - assert!(local.get("remote").is_none()); - // My Space (profile customization) is removed: its commands do not exist, so no capability - // file grants them and neither the command manifest nor the handler names them. - let (build, desktop) = (include_str!("../build.rs"), include_str!("../src/desktop.rs")); - for gone in [ - "profile_owner_get", "profile_create", "profile_api", "profile_media_pick", "profile_media_still", "profile_media_upload", "profile_still_to_x", "profile_view_get", "profile_view_set", - "bt_profiles_resolve", "bt_media_get", "bt_profile_link_seen", - ] { - let permission = format!("allow-{}", gone.replace('_', "-")); - for (name, capability) in [("x-capture", &x), ("drafts-popover", &popover), ("tab-chrome", &tabs), ("workspace-local", &local)] { - assert!(!capability["permissions"].as_array().unwrap().iter().any(|p| p == permission.as_str()), "{name} grants {permission}"); - } - assert!(!build.contains(&format!("\"{gone}\"")), "build.rs still lists {gone}"); - assert!(!desktop.contains(gone), "desktop.rs still names {gone}"); - } - assert_eq!(local["webviews"], serde_json::json!(["shell"])); - - // Update commands are shell-only; no capability exposes the updater/process plugin JS APIs. - let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::>(); - // Account profiles (separate X logins) are managed only by the shell. - // Drafts are inserted and alert keywords edited only by the shell; X tabs only ask/report. - for name in [ - "allow-update-status", "allow-apply-update", "allow-app-ready", "allow-profiles-get", "allow-profiles-set", - "allow-tab-insert-text", "allow-alerts-get", "allow-alerts-set", "allow-draft-list", "allow-draft-revisions", - // Google Fonts downloads (network), account/pane themes and the builder preview: shell only. - "allow-gfonts-installed", "allow-gfonts-install", "allow-gfonts-remove", "allow-gfonts-faces", "allow-gfonts-preview", - "allow-theme-scopes-get", "allow-theme-scopes-set", "allow-theme-preview", - // Installing themes/plugins (links, dropped files, the signed store; owner-approved 2026-09-28): - // only the shell, after its confirmation screen. X tabs and web pages cannot install anything. - "allow-install-pending", "allow-install-stage-link", "allow-install-stage-file", "allow-install-cancel", - "allow-install-commit", "allow-install-records", "allow-install-forget", "allow-plugin-remove", - "allow-store-catalog", "allow-store-stage", - ] { - assert!(perms(&local).iter().any(|p| p == name), "shell lacks {name}"); - assert!(!perms(&x).iter().chain(perms(&tabs).iter()).any(|p| p == name), "tab has {name}"); - } - for p in perms(&local).iter().chain(perms(&x).iter()).chain(perms(&tabs).iter()) { - assert!( - !p.starts_with("updater:") && !p.starts_with("process:") && !p.starts_with("notification:") && !p.starts_with("deep-link:"), - "plugin API exposed: {p}" - ); - } -} - -#[test] -fn theme_accepts_only_known_presets_and_hex_accents() { - let script = |theme: XTheme| theme.script(&X_THEMES, &SITE); - let valid = XTheme { preset: "night-ink".into(), accent: Some("#1d9bf0".into()), ..x_default() }; - let text = script(valid).unwrap(); - assert!(text.contains("location.origin!=='https://x.com'")); - assert!(text.contains(r#""preset":"night-ink""#)); - assert!(x_default().is_default(&X_THEMES)); - for preset in ["x", "classic", "night-ink", "terminal", "paper"] { - assert!(script(XTheme { preset: preset.into(), ..x_default() }).is_ok()); - } - for hostile in ["evil", "x;display:none", "", ""] { - assert!(script(XTheme { preset: hostile.into(), ..x_default() }).is_err(), "accepted {hostile}"); - } - for hostile in ["red", "#fff", "url(https://evil.test)", "#000000;display:none", "", "<script>", "javascript:alert(1)", "a\u{202e}b"] { - assert!(set(bad).validated().is_err(), "{bad:?}"); - } -} - -#[test] -fn shell_raise_is_shell_only() { - let read = |json: &str| serde_json::from_str::(json).unwrap(); - let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::>(); - let local = perms(&read(include_str!("../capabilities/workspace-local.json"))); - let x = perms(&read(include_str!("../capabilities/x-capture.json"))); - let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json"))); - let popover = perms(&read(include_str!("../capabilities/drafts-popover.json"))); - // Re-orders the shell webview over the tabs for its own overlays: only the shell may. - assert!(local.iter().any(|p| p == "allow-shell-raise"), "shell lacks allow-shell-raise"); - assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == "allow-shell-raise"), "a tab or the popover can raise the shell"); - // The command is the framework's (waybetter-host `app`). - waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), "shell_raise", "shell_only(&webview)?"); - waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")).assert_shell_only("shell_raise"); -} - -/// The wayBetter host's own commands (tab layout and actions, shell raise, external links, window -/// menu, profiles, language; tab shortcuts and link drags) keep their callers in X's capability -/// files, and every command wrapper checks its caller first. Every capability file is read, so a -/// new one cannot quietly widen them. -#[test] -fn host_commands_keep_their_callers() { - use waybetter_host::testkit::{assert_guarded, assert_host_guards, Capabilities}; - let caps = Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")); - caps.assert_host_commands(); - assert_host_guards(); - // Only Rust drives the plugins; no capability exposes their JS APIs. - caps.assert_no_plugin_apis(&["updater", "process", "notification", "deep-link", "single-instance", "window-state", "opener", "dialog"]); - // X's own: vault, settings, themes, installs, backups, sync, dev mode, updates are the shell's. - for command in [ - "draft_list", "draft_save", "draft_revisions", "draft_status", "observation_list", "data_export", - "gfonts_installed", "gfonts_install", "gfonts_remove", "gfonts_faces", "gfonts_preview", - "tab_run_command", "workspaces_get", "workspaces_set", "backup_collect", "backup_export_file", "backup_open_file", "backup_apply", - "sync_status", "sync_choose_folder", "sync_unlock", "sync_lock", "sync_disable", "sync_configure", "sync_push", "sync_check", "sync_open", "sync_mark_pulled", - "dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error", - "plugins_list", "plugin_set_enabled", "plugins_open_folder", "plugin_remove", - "install_pending", "install_stage_link", "install_stage_file", "install_stage_opened", "install_cancel", "install_commit", "install_records", "install_forget", "store_catalog", "store_stage", - "custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set", "column_configure", - "update_status", "update_check", "apply_update", "app_ready", "tab_insert_text", "alerts_get", "alerts_set", - "media_get", "media_set", "gallery_add_folder", "gallery_list", "gallery_thumb", "clip_list", "clip_thumb", "clip_remove", "tab_attach_image", - ] { - caps.assert_shell_only(command); - } - // Tabs, about themselves only; the X-only ones are granted only on https://x.com. - for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert"] { - caps.assert_tab_only(command); - } - let desktop = include_str!("../src/desktop.rs"); - assert_guarded(desktop, "tab_insert_text", "shell_only(&webview)?"); - // The vault commands are the host's: shell-only in the capability files and each checking its caller - // first; X's own handler no longer has them, and X turns the vault on with its rules. - caps.assert_shell_only_group(waybetter_host::commands::VAULT); - waybetter_host::testkit::assert_vault_guards(); - assert!(desktop.contains(".with_vault("), "the app enables the host's vault"); - for command in waybetter_host::commands::VAULT { - assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only"); - } - // The updater commands are the shell's too, each checking its caller first; `update_check` runs the - // timer's own check once (no other endpoint), and tabs have no updater command at all. - caps.assert_shell_only_group(waybetter_host::commands::UPDATE); - waybetter_host::testkit::assert_update_guards(); - assert!(desktop.contains(".with_updates("), "the app enables the updater"); - // Plugins, custom CSS, the tweak flags and the browser settings are served by the host: they - // are in its shell-only list (checked against the capability files and for their caller - // check above), and X's own handler no longer has them. - let host = include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"); - for command in ["plugins_list", "plugin_set_enabled", "plugins_open_folder", "custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set"] { - assert!(waybetter_host::commands::SHELL.contains(&command), "{command} is a host shell-only command"); - assert_guarded(host, command, "shell_only(&webview)?"); - assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only"); - } - assert_guarded(desktop, "tab_report", "tab_only(&webview)?"); - // Opening the panel from a page is the host's site command: only X tabs on x.com have it. - caps.assert_site_only("open_panel", &better_twttr::site::SITE); - assert!(!desktop.contains("fn open_panel("), "open_panel is served by the host only"); -} - -#[test] -fn install_store_media_and_font_commands_are_shell_only() { - // The store's commands are the framework's (waybetter-host `store_commands`, served because - // this app sets `AppConfig::store`): shell-only in the capability files, and each wrapper - // checks shell_only before anything else. - use waybetter_host::testkit::assert_guarded; - let caps = waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")); - caps.assert_shell_only_group(waybetter_host::commands::STORE); - waybetter_host::testkit::assert_store_guards(); - // The media panel's commands are the framework's too (AppConfig::with_media): shell-only, each - // checking its caller first; only attaching to X's composer is the app's own. - caps.assert_shell_only_group(waybetter_host::commands::MEDIA); - waybetter_host::testkit::assert_media_guards(); - // Workspaces and encrypted sync too (AppConfig::with_sync; workspaces are served for every app). - caps.assert_shell_only_group(waybetter_host::commands::WORKSPACES); - caps.assert_shell_only_group(waybetter_host::commands::SYNC); - waybetter_host::testkit::assert_sync_guards(); - // The settings backup's commands are the framework's too (what it carries is the app's hooks). - caps.assert_shell_only_group(waybetter_host::commands::BACKUP); - waybetter_host::testkit::assert_backup_guards(); - // Developer mode (AppConfig::with_dev): the framework's commands; the theme file is the app's hook. - caps.assert_shell_only_group(waybetter_host::commands::DEV); - waybetter_host::testkit::assert_dev_guards(); - assert!(include_str!("../src/desktop.rs").contains(".with_dev()"), "the app enables developer mode"); - assert!(include_str!("../src/desktop.rs").contains(".with_sync()"), "the app enables sync"); - // Site themes too (AppConfig::with_themes): the framework's commands; X only names its built-in themes. - caps.assert_shell_only_group(waybetter_host::commands::THEME); - waybetter_host::testkit::assert_theme_guards(); - assert!(include_str!("../src/desktop.rs").contains(".with_themes(&crate::theme::THEMES)"), "the app configures its themes"); - // The keyword alert settings (AppConfig::with_alerts): the framework's commands; the hit and its notification are X's. - caps.assert_shell_only_group(waybetter_host::commands::ALERTS); - waybetter_host::testkit::assert_alerts_guards(); - assert!(include_str!("../src/desktop.rs").contains(".with_alerts(&alerts::ALERTS)"), "the app configures its alerts"); - // Google Fonts too (AppConfig::with_fonts). - caps.assert_shell_only_group(waybetter_host::commands::GFONTS); - waybetter_host::testkit::assert_fonts_guards(); - assert!(include_str!("../src/desktop.rs").contains(".with_fonts()"), "the app enables Google Fonts"); - assert!(include_str!("../src/desktop.rs").contains(".with_media()"), "the app enables the media panel"); - assert_guarded(include_str!("../src/desktop_media.rs"), "tab_attach_image", "shell_only(&webview)?"); - let source = include_str!("../../../../waybetter/crates/waybetter-host/src/store_commands.rs"); - // Links only ever propose an install: the deep-link handler queues and emits, nothing more. - let received = &source[source.find("fn received(").unwrap()..]; - let received = &received[..received.find("\n}").unwrap()]; - assert!(!received.contains("commit") && !received.contains("stage"), "a link must not stage or install by itself"); - // The app's store settings: X's id and the framework's hosts; the compiled-in key; and it asks - // the framework to serve the store. - let settings = include_str!("../src/distribution.rs"); - assert!(settings.contains("app_id: \"x\"") && settings.contains("allowed_hosts: DEFAULT_HOSTS") && settings.contains("include_str!(\"../keys/store.pub\")")); - assert!(include_str!("../src/desktop.rs").contains(".with_store(&crate::distribution::STORE)"), "the app configures the store"); -} - -/// The popover's webview and session are the host's; X's commands reached by the popover each check -/// their caller first (the popover webview with the open session), and inserting a draft only -/// focuses the tab and types (`compose` scripts), never clicks Post. -#[test] -fn popover_commands_check_their_caller_and_never_post() { - use waybetter_host::testkit::assert_guarded; - let source = include_str!("../src/desktop_popover.rs"); - assert_guarded(source, "popover_state", "host::session_of(&webview)"); - for command in ["popover_drafts", "popover_insert", "popover_open_vault"] { - assert_guarded(source, command, "host::current(&webview,"); - } - // popover_close only acts for the current popover, and does nothing otherwise. - let close = &source[source.find("fn popover_close(").unwrap()..]; - let close = &close[..close.find(" -}").unwrap()]; - assert!(close.contains("host::current(&webview, seq).is_ok()")); - let insert = &source[source.find("fn popover_insert(").unwrap()..]; - let insert = &insert[..insert.find(" -}").unwrap()]; - assert!(insert.contains("compose::insert_script") && insert.contains("compose::append_script")); - assert!(!insert.contains("click") && !insert.contains("submit") && !insert.contains("Post"), "popover_insert never posts"); - assert!(insert.contains("policy::x_origin(&url)"), "only into X tabs"); - // The host's popover check: only a `popover-` webview on the app's own local origin. - let host = include_str!("../../../../waybetter/crates/waybetter-host/src/popover_host.rs"); - let only = &host[host.find("pub fn popover_only(").unwrap()..]; - let only = &only[..only.find(" -}").unwrap()]; - assert!(only.contains("popover::valid_label(webview.label())") && only.contains("local_navigation_allowed")); -} +use url::Url; +use better_twttr::{policy::*, site::SITE, theme::{x_default, XTheme, X_THEMES}}; + +#[test] +fn exact_origin_and_window_scope_is_enforced() { + let x = Url::parse("https://x.com/home").unwrap(); + assert!(require_capture("tab-1", &x).is_ok()); + assert!(require_capture("shell", &x).is_err()); + assert!(require_local( + "tab-1", + &Url::parse("tauri://localhost/index.html").unwrap(), + false + ) + .is_err()); + for value in [ + "https://evil.x.com/home", + "http://x.com/home", + "https://x.com:8443/home", + "https://x.com.evil.test/home", + "https://x.com@evil.test/home", + "https://evil@x.com/home", + "file:///tmp/index.html", + "https://twitter.com/home", + ] { + assert!( + require_capture("tab-1", &Url::parse(value).unwrap()).is_err(), + "accepted {value}" + ); + } +} + +#[test] +fn local_origin_has_no_production_localhost_backdoor() { + for value in [ + "tauri://localhost/index.html", + "http://tauri.localhost/index.html", + "https://tauri.localhost/index.html", + ] { + assert!(require_local("shell", &Url::parse(value).unwrap(), false).is_ok()); + } + assert!(require_local("shell", &Url::parse("http://127.0.0.1:1420/").unwrap(), true).is_ok()); + for value in [ + "https://x.com/", + "http://127.0.0.1:1420/", + "http://localhost:1420/", + "https://tauri.localhost.evil.test/", + "tauri://evil/", + ] { + assert!( + require_local("shell", &Url::parse(value).unwrap(), false).is_err(), + "accepted {value}" + ); + } +} + +#[test] +fn navigation_allows_only_x_and_specific_legacy_login_routes() { + for value in [ + "https://x.com/home", + "https://twitter.com/login", + "https://twitter.com/i/flow/login", + "https://twitter.com/i/flow/login/step", + ] { + assert!(x_navigation_allowed(&Url::parse(value).unwrap())); + } + for value in [ + "https://twitter.com/home", + "https://t.co/test", + "https://accounts.google.com/", + "javascript:alert(1)", + "mailto:test@example.com", + "https://twitter.com/login/evil", + ] { + assert!( + !x_navigation_allowed(&Url::parse(value).unwrap()), + "accepted {value}" + ); + } +} + +#[test] +fn remote_capabilities_cannot_reach_the_vault() { + let read = |json: &str| serde_json::from_str::(json).unwrap(); + let x = read(include_str!("../capabilities/x-capture.json")); + // Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question. + // Chat+ link previews (owner-approved, 2026-10-10): one link at a time, from a chat page only. + assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"])); + let desktop = include_str!("../src/desktop.rs"); + waybetter_host::testkit::assert_guarded(desktop, "link_preview", "policy::require_capture(webview.label(), &webview.url()"); + assert!(desktop.contains("link_preview::is_chat_page(page.path())"), "link previews answer chat pages only"); + for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] { + assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}"); + } + + // The drafts popover is local-only and gets nothing beyond its own five commands. + let popover = read(include_str!("../capabilities/drafts-popover.json")); + assert_eq!(popover["webviews"], serde_json::json!(["popover-*"])); + assert_eq!(popover["local"], true); + assert!(popover.get("remote").is_none()); + assert_eq!(popover["permissions"], serde_json::json!(["allow-popover-state", "allow-popover-drafts", "allow-popover-insert", "allow-popover-close", "allow-popover-open-vault"])); + assert_eq!(x["webviews"], serde_json::json!(["tab-*"])); + assert_eq!(x["remote"]["urls"], serde_json::json!(["https://x.com/*"])); + assert_eq!(x["local"], false); + // Linux joined with the same permissions (owner request, 2026-09-28): platforms only, no new grants. + assert_eq!(x["platforms"], serde_json::json!(["macOS", "windows", "linux"])); + + let tabs = read(include_str!("../capabilities/tab-chrome.json")); + assert_eq!(tabs["permissions"], serde_json::json!(["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"])); + assert_eq!(tabs["webviews"], serde_json::json!(["tab-*"])); + assert_eq!(tabs["local"], false); + + let local = read(include_str!("../capabilities/workspace-local.json")); + assert!(local.get("remote").is_none()); + // My Space (profile customization) is removed: its commands do not exist, so no capability + // file grants them and neither the command manifest nor the handler names them. + let (build, desktop) = (include_str!("../build.rs"), include_str!("../src/desktop.rs")); + for gone in [ + "profile_owner_get", "profile_create", "profile_api", "profile_media_pick", "profile_media_still", "profile_media_upload", "profile_still_to_x", "profile_view_get", "profile_view_set", + "bt_profiles_resolve", "bt_media_get", "bt_profile_link_seen", + ] { + let permission = format!("allow-{}", gone.replace('_', "-")); + for (name, capability) in [("x-capture", &x), ("drafts-popover", &popover), ("tab-chrome", &tabs), ("workspace-local", &local)] { + assert!(!capability["permissions"].as_array().unwrap().iter().any(|p| p == permission.as_str()), "{name} grants {permission}"); + } + assert!(!build.contains(&format!("\"{gone}\"")), "build.rs still lists {gone}"); + assert!(!desktop.contains(gone), "desktop.rs still names {gone}"); + } + assert_eq!(local["webviews"], serde_json::json!(["shell"])); + + // Update commands are shell-only; no capability exposes the updater/process plugin JS APIs. + let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::>(); + // Account profiles (separate X logins) are managed only by the shell. + // Drafts are inserted and alert keywords edited only by the shell; X tabs only ask/report. + for name in [ + "allow-update-status", "allow-apply-update", "allow-app-ready", "allow-profiles-get", "allow-profiles-set", + "allow-tab-insert-text", "allow-alerts-get", "allow-alerts-set", "allow-draft-list", "allow-draft-revisions", + // Google Fonts downloads (network), account/pane themes and the builder preview: shell only. + "allow-gfonts-installed", "allow-gfonts-install", "allow-gfonts-remove", "allow-gfonts-faces", "allow-gfonts-preview", + "allow-theme-scopes-get", "allow-theme-scopes-set", "allow-theme-preview", + // Installing themes/plugins (links, dropped files, the signed store; owner-approved 2026-09-28): + // only the shell, after its confirmation screen. X tabs and web pages cannot install anything. + "allow-install-pending", "allow-install-stage-link", "allow-install-stage-file", "allow-install-cancel", + "allow-install-commit", "allow-install-records", "allow-install-forget", "allow-plugin-remove", + "allow-store-catalog", "allow-store-stage", + ] { + assert!(perms(&local).iter().any(|p| p == name), "shell lacks {name}"); + assert!(!perms(&x).iter().chain(perms(&tabs).iter()).any(|p| p == name), "tab has {name}"); + } + for p in perms(&local).iter().chain(perms(&x).iter()).chain(perms(&tabs).iter()) { + assert!( + !p.starts_with("updater:") && !p.starts_with("process:") && !p.starts_with("notification:") && !p.starts_with("deep-link:"), + "plugin API exposed: {p}" + ); + } +} + +#[test] +fn theme_accepts_only_known_presets_and_hex_accents() { + let script = |theme: XTheme| theme.script(&X_THEMES, &SITE); + let valid = XTheme { preset: "night-ink".into(), accent: Some("#1d9bf0".into()), ..x_default() }; + let text = script(valid).unwrap(); + assert!(text.contains("location.origin!=='https://x.com'")); + assert!(text.contains(r#""preset":"night-ink""#)); + assert!(x_default().is_default(&X_THEMES)); + for preset in ["x", "classic", "night-ink", "terminal", "paper"] { + assert!(script(XTheme { preset: preset.into(), ..x_default() }).is_ok()); + } + for hostile in ["evil", "x;display:none", "", ""] { + assert!(script(XTheme { preset: hostile.into(), ..x_default() }).is_err(), "accepted {hostile}"); + } + for hostile in ["red", "#fff", "url(https://evil.test)", "#000000;display:none", "