diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ee8868ad92..ca7ea90a8b 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,41 @@ 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` 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", + "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 +1146,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)"); + } } 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:?}" ); }