Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
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",
Expand All @@ -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;

Expand Down Expand Up @@ -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!(
Expand Down
69 changes: 59 additions & 10 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)");
}
}
44 changes: 36 additions & 8 deletions crates/buzz-media/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
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
// `<?xml ...><html xmlns=...>` 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"<?xml version=\"1.0\"?><html xmlns=\"http://www.w3.org/1999/xhtml\"></html>";
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:?}"
);
}

Expand Down