From 2f1e924745ed4dd8047b6ae62277d9f115b5929f Mon Sep 17 00:00:00 2001 From: Abraham Prieto Date: Tue, 4 Aug 2026 13:09:40 -0400 Subject: [PATCH 1/2] fix(cli): allow generic file uploads through buzz messages send --file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buzz-cli's upload_file() rejected any MIME type outside a narrow image/video allowlist before the file ever reached the relay. The relay's /upload endpoint already routes non-image/video bytes through buzz_media::process_file_upload — a generic-file path with its own magic-byte sniffing, size cap, and deny-list for active-content/ executable types (buzz-media/src/validation.rs) — so the CLI's stricter local check was purely redundant and blocked legitimate attachments (docs, text, PDFs) that the server already supports. Replace the local allowlist with a deny-list mirroring the relay's BLOCKED_FILE_MIME_TYPES; anything else now uploads and lets the relay be the authoritative validator, as designed. Also fixes the markdown embed for non-image/video uploads: they were rendered as broken `![image](url)` embeds. Desktop's resolveFileCard renderer expects a markdown *link* (`[filename](url)`) plus the imeta MIME to show a generic-file download card, so route those through a `[filename](url)` link using the original filename (Blossom URLs are content-hash-addressed, not human-readable). Reported by Abraham in #buzz-ops 2026-08-04: Antigravity shared a `file:///home/...` artifact link that only resolved on the VPS, not from a remote Desktop client — this closes the underlying gap that made pasting file:// paths the only option. --- crates/buzz-cli/src/client.rs | 40 ++++++++++++-- crates/buzz-cli/src/commands/messages.rs | 69 ++++++++++++++++++++---- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ee8868ad92..48238264cd 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -60,7 +60,8 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { tag } -/// MIME types accepted for upload. +/// MIME types recognized as image/video for the size-tier and imeta decision. +/// Not a security allowlist — see `BLOCKED_MIMES` below. const ALLOWED_MIMES: &[&str] = &[ "image/jpeg", "image/png", @@ -69,9 +70,40 @@ const ALLOWED_MIMES: &[&str] = &[ "video/mp4", ]; +/// MIME types rejected client-side before upload, mirroring the relay's +/// generic-file deny-list (`buzz_media::validation::BLOCKED_FILE_MIME_TYPES`). +/// Anything not in `ALLOWED_MIMES` and not here (docs, archives, text, data) +/// is sent to `/upload` and handled by the relay's generic-file path, which +/// does the authoritative magic-byte sniffing and validation server-side — +/// this list only saves a round trip for the categories we already know the +/// relay will refuse. +const BLOCKED_MIMES: &[&str] = &[ + // Active web content — stored-XSS vectors. + "text/html", + "application/xhtml+xml", + "image/svg+xml", + "application/javascript", + "text/javascript", + // Native executables / installers. + "application/x-msdownload", // .exe / .dll + "application/x-executable", // ELF + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", // Mach-O + "application/x-sharedlib", + "application/x-elf", + "application/x-msi", + "application/vnd.android.package-archive", // .apk + "application/x-apple-diskimage", // .dmg +]; + /// Maximum file size for image uploads (50 MB). const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; +/// Maximum file size for generic file uploads (100 MB) — matches the relay's +/// `default_max_file_bytes` (buzz_media::config); the relay enforces the +/// authoritative cap regardless. +const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + /// Maximum file size for video uploads (500 MB). const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; @@ -1113,15 +1145,17 @@ impl BuzzClient { .map(|t| t.mime_type().to_string()) .unwrap_or_else(|| "application/octet-stream".to_string()); - if !ALLOWED_MIMES.contains(&mime.as_str()) { + if BLOCKED_MIMES.contains(&mime.as_str()) { return Err(CliError::Usage(format!("unsupported file type: {mime}"))); } // 3. Size check let max = if mime.starts_with("video/") { MAX_VIDEO_BYTES - } else { + } else if ALLOWED_MIMES.contains(&mime.as_str()) { MAX_IMAGE_BYTES + } else { + MAX_FILE_BYTES }; if bytes.len() as u64 > max { return Err(CliError::Usage(format!( diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..7a7d7e5143 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -561,6 +561,28 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri matches } +/// Build the markdown fragment embedded in a message for one uploaded file. +/// +/// Images and video use `![...](url)` so the desktop/mobile renderers treat +/// them as inline media. Everything else (docs, archives, text) uses a plain +/// `[filename](url)` link — the desktop `resolveFileCard` renderer keys off a +/// markdown *link* (not an image embed) plus the accompanying imeta MIME to +/// show a generic-file download card. The link text carries the original +/// filename since Blossom is content-addressed and the URL itself is a hash. +fn media_markdown_fragment(mime_type: &str, url: &str, file_path: &str) -> String { + if mime_type.starts_with("video/") { + format!("![video]({url})") + } else if mime_type.starts_with("image/") { + format!("![image]({url})") + } else { + let filename = std::path::Path::new(file_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file"); + format!("[{filename}]({url})") + } +} + pub struct SendMessageParams { pub channel_id: String, pub content: String, @@ -619,13 +641,12 @@ pub async fn cmd_send_message( .await .map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?; media_tags.push(crate::client::build_imeta_tag(&desc)); - if desc.mime_type.starts_with("video/") { - media_content.push_str("\n![video]("); - } else { - media_content.push_str("\n![image]("); - } - media_content.push_str(&desc.url); - media_content.push(')'); + media_content.push('\n'); + media_content.push_str(&media_markdown_fragment( + &desc.mime_type, + &desc.url, + file_path, + )); } let final_content = if media_content.is_empty() { p.content.clone() @@ -993,9 +1014,9 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, + media_markdown_fragment, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1372,4 +1393,32 @@ mod tests { ]; assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } + + #[test] + fn media_markdown_fragment_embeds_images() { + let md = media_markdown_fragment("image/png", "https://relay/x.png", "/tmp/photo.png"); + assert_eq!(md, "![image](https://relay/x.png)"); + } + + #[test] + fn media_markdown_fragment_embeds_video() { + let md = media_markdown_fragment("video/mp4", "https://relay/x.mp4", "/tmp/clip.mp4"); + assert_eq!(md, "![video](https://relay/x.mp4)"); + } + + #[test] + fn media_markdown_fragment_links_generic_files_with_original_filename() { + let md = media_markdown_fragment( + "application/pdf", + "https://relay/deadbeef.pdf", + "/home/abraham/reports/q3-plan.pdf", + ); + assert_eq!(md, "[q3-plan.pdf](https://relay/deadbeef.pdf)"); + } + + #[test] + fn media_markdown_fragment_falls_back_to_file_when_path_has_no_filename() { + let md = media_markdown_fragment("text/plain", "https://relay/x.txt", "/"); + assert_eq!(md, "[file](https://relay/x.txt)"); + } } From dae00eebc74ce7950239c189fa963a30da73d13d Mon Sep 17 00:00:00 2001 From: Abraham Prieto Date: Tue, 4 Aug 2026 13:25:53 -0400 Subject: [PATCH 2/2] feat(media): allow text/html on the generic file-upload path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision (Abraham, #buzz-ops 2026-08-04): accept the residual risk of hosting text/html attachments given the existing defence in depth — generic files are already served with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: default-src 'none'`, which prevents an accepted HTML upload from executing or rendering as active content in any client that respects those headers. Use case: sharing generated HTML reports/exports. `application/xhtml+xml` stays blocked (not part of the request). JS and SVG stay blocked (classic stored-XSS carriers, no legitimate need raised). Executables stay blocked — the same conversation settled on zipping installers/binaries instead, which was already supported. Mirrors the same removal in buzz-cli's client-side BLOCKED_MIMES so the CLI doesn't reject an upload the relay now accepts. --- crates/buzz-cli/src/client.rs | 5 ++-- crates/buzz-media/src/validation.rs | 44 +++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 48238264cd..ca7ea90a8b 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -78,8 +78,9 @@ const ALLOWED_MIMES: &[&str] = &[ /// this list only saves a round trip for the categories we already know the /// relay will refuse. const BLOCKED_MIMES: &[&str] = &[ - // Active web content — stored-XSS vectors. - "text/html", + // Active web content — stored-XSS vectors. `text/html` is intentionally + // absent: the relay accepts it on the generic-file path (owner decision, + // 2026-08-04 — see BLOCKED_FILE_MIME_TYPES in buzz-media), so mirror that. "application/xhtml+xml", "image/svg+xml", "application/javascript", diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 450f8f353e..78a752e3d7 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -69,12 +69,19 @@ pub(crate) fn looks_like_mp4_iso_bmff(bytes: &[u8]) -> bool { /// neutralises them — this allowlist-of-denials is defence in depth, so a future /// header regression can't turn an uploaded blob into a stored-XSS vector. /// -/// HTML, JS, and SVG are the classic stored-XSS carriers. Native executables are +/// JS and SVG are the classic stored-XSS carriers. Native executables are /// blocked because there's no legitimate reason to host them inline in chat and -/// they're a malware-distribution risk. +/// they're a malware-distribution risk (share an executable as a zip instead — +/// `application/zip` is not on this list). +/// +/// `text/html` is intentionally *not* blocked: tenant owner decision +/// (2026-08-04, requested in `#buzz-ops`) accepting the residual risk given the +/// attachment/nosniff/CSP defence above — legitimate use case is sharing +/// generated HTML reports/exports. `application/xhtml+xml` stays blocked; it +/// wasn't part of the request and is rare enough that keeping it out costs +/// nothing. const BLOCKED_FILE_MIME_TYPES: &[&str] = &[ // Active web content — stored-XSS vectors. - "text/html", "application/xhtml+xml", "image/svg+xml", "application/javascript", @@ -2586,14 +2593,35 @@ mod tests { } #[test] - fn test_validate_file_html_rejected() { - // HTML is a stored-XSS carrier — blocked even though headers neutralise it. + fn test_validate_file_html_accepted_and_forced_to_download() { + // HTML is allowed on the generic-file path (owner decision, 2026-08-04) + // but must never be eligible for inline rendering — `serve_inline` + // forces it to `attachment`, and the response still carries `nosniff` + // + `CSP: default-src 'none'` (asserted at the relay response layer, + // not here), so an accepted upload can't execute as active content. let config = test_config(); let html = b""; - let result = validate_file_content(html, &config); + let (mime, _ext) = validate_file_content(html, &config).unwrap(); + assert_eq!(mime, "text/html"); + assert!(!serve_inline(&mime)); + } + + #[test] + fn test_validate_file_xml_declared_xhtml_is_not_reclassified_as_html() { + // `infer` doesn't have a distinct XHTML magic-byte signature — a real + // `` document sniffs as `text/xml` (already + // unblocked, not part of the 2026-08-04 HTML decision), not + // `text/html`. `application/xhtml+xml` stays in BLOCKED_FILE_MIME_TYPES + // defensively in case a future `infer` version adds that detection, + // but today's coverage for XHTML specifically is this: it must not + // come out as `text/html`, which would make it eligible for the + // owner's HTML-only allowance under the wrong label. + let config = test_config(); + let xhtml = b""; + let result = validate_file_content(xhtml, &config); assert!( - matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"), - "expected DisallowedContentType(text/html), got {result:?}" + !matches!(result, Ok((ref m, _)) if m == "text/html"), + "xhtml content must never be classified as text/html, got {result:?}" ); }