Skip to content

Commit dae00ee

Browse files
committed
feat(media): allow text/html on the generic file-upload path
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.
1 parent 2f1e924 commit dae00ee

2 files changed

Lines changed: 39 additions & 10 deletions

File tree

crates/buzz-cli/src/client.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ const ALLOWED_MIMES: &[&str] = &[
7878
/// this list only saves a round trip for the categories we already know the
7979
/// relay will refuse.
8080
const BLOCKED_MIMES: &[&str] = &[
81-
// Active web content — stored-XSS vectors.
82-
"text/html",
81+
// Active web content — stored-XSS vectors. `text/html` is intentionally
82+
// absent: the relay accepts it on the generic-file path (owner decision,
83+
// 2026-08-04 — see BLOCKED_FILE_MIME_TYPES in buzz-media), so mirror that.
8384
"application/xhtml+xml",
8485
"image/svg+xml",
8586
"application/javascript",

crates/buzz-media/src/validation.rs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,19 @@ pub(crate) fn looks_like_mp4_iso_bmff(bytes: &[u8]) -> bool {
6969
/// neutralises them — this allowlist-of-denials is defence in depth, so a future
7070
/// header regression can't turn an uploaded blob into a stored-XSS vector.
7171
///
72-
/// HTML, JS, and SVG are the classic stored-XSS carriers. Native executables are
72+
/// JS and SVG are the classic stored-XSS carriers. Native executables are
7373
/// blocked because there's no legitimate reason to host them inline in chat and
74-
/// they're a malware-distribution risk.
74+
/// they're a malware-distribution risk (share an executable as a zip instead —
75+
/// `application/zip` is not on this list).
76+
///
77+
/// `text/html` is intentionally *not* blocked: tenant owner decision
78+
/// (2026-08-04, requested in `#buzz-ops`) accepting the residual risk given the
79+
/// attachment/nosniff/CSP defence above — legitimate use case is sharing
80+
/// generated HTML reports/exports. `application/xhtml+xml` stays blocked; it
81+
/// wasn't part of the request and is rare enough that keeping it out costs
82+
/// nothing.
7583
const BLOCKED_FILE_MIME_TYPES: &[&str] = &[
7684
// Active web content — stored-XSS vectors.
77-
"text/html",
7885
"application/xhtml+xml",
7986
"image/svg+xml",
8087
"application/javascript",
@@ -2586,14 +2593,35 @@ mod tests {
25862593
}
25872594

25882595
#[test]
2589-
fn test_validate_file_html_rejected() {
2590-
// HTML is a stored-XSS carrier — blocked even though headers neutralise it.
2596+
fn test_validate_file_html_accepted_and_forced_to_download() {
2597+
// HTML is allowed on the generic-file path (owner decision, 2026-08-04)
2598+
// but must never be eligible for inline rendering — `serve_inline`
2599+
// forces it to `attachment`, and the response still carries `nosniff`
2600+
// + `CSP: default-src 'none'` (asserted at the relay response layer,
2601+
// not here), so an accepted upload can't execute as active content.
25912602
let config = test_config();
25922603
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
2593-
let result = validate_file_content(html, &config);
2604+
let (mime, _ext) = validate_file_content(html, &config).unwrap();
2605+
assert_eq!(mime, "text/html");
2606+
assert!(!serve_inline(&mime));
2607+
}
2608+
2609+
#[test]
2610+
fn test_validate_file_xml_declared_xhtml_is_not_reclassified_as_html() {
2611+
// `infer` doesn't have a distinct XHTML magic-byte signature — a real
2612+
// `<?xml ...><html xmlns=...>` document sniffs as `text/xml` (already
2613+
// unblocked, not part of the 2026-08-04 HTML decision), not
2614+
// `text/html`. `application/xhtml+xml` stays in BLOCKED_FILE_MIME_TYPES
2615+
// defensively in case a future `infer` version adds that detection,
2616+
// but today's coverage for XHTML specifically is this: it must not
2617+
// come out as `text/html`, which would make it eligible for the
2618+
// owner's HTML-only allowance under the wrong label.
2619+
let config = test_config();
2620+
let xhtml = b"<?xml version=\"1.0\"?><html xmlns=\"http://www.w3.org/1999/xhtml\"></html>";
2621+
let result = validate_file_content(xhtml, &config);
25942622
assert!(
2595-
matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"),
2596-
"expected DisallowedContentType(text/html), got {result:?}"
2623+
!matches!(result, Ok((ref m, _)) if m == "text/html"),
2624+
"xhtml content must never be classified as text/html, got {result:?}"
25972625
);
25982626
}
25992627

0 commit comments

Comments
 (0)