fix(desktop): strip GIF metadata extensions before upload (#2425)

Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-22 15:39:05 -07:00
committed by GitHub
co-authored by npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c
parent fd967c6ea4
commit 47d7eb6982
3 changed files with 230 additions and 1 deletions
+8 -1
View File
@@ -212,11 +212,18 @@ fn is_animated_image(body: &[u8], mime: &str) -> bool {
}
}
fn sanitize_image_for_upload(body: Vec<u8>, mime: &str) -> Result<Vec<u8>, String> {
pub(crate) fn sanitize_image_for_upload(body: Vec<u8>, mime: &str) -> Result<Vec<u8>, String> {
let format = match mime {
"image/jpeg" => image::ImageFormat::Jpeg,
"image/png" => image::ImageFormat::Png,
"image/webp" => image::ImageFormat::WebP,
// GIF is never re-encoded (that would destroy animation timing);
// metadata extensions are stripped structurally instead. Unparseable
// payloads pass through — the relay's validator is the authority.
"image/gif" => {
let stripped = super::media_gif::strip_gif_metadata(&body);
return Ok(stripped.unwrap_or(body));
}
_ => return Ok(body),
};
+221
View File
@@ -0,0 +1,221 @@
//! Structural GIF metadata stripping for the upload sanitizer.
//!
//! Split out of `media.rs` to keep that file under the desktop line-size
//! limit. The relay rejects media carrying metadata (`MetadataForbidden` in
//! buzz-media's `validate_gif_metadata_free`); these helpers drop the GIF
//! metadata channels it forbids without re-encoding, so animation timing,
//! disposal, and pixel data survive byte-identical.
/// Walk length-prefixed GIF data sub-blocks starting at `i`; return the index
/// just past the block terminator.
fn gif_sub_blocks_end(body: &[u8], mut i: usize) -> Option<usize> {
loop {
let len = *body.get(i)? as usize;
i += 1;
if len == 0 {
return Some(i);
}
i = i.checked_add(len).filter(|&end| end <= body.len())?;
}
}
/// Strip metadata channels from a GIF without re-encoding.
///
/// GIF carries three unrestricted metadata channels — comment extensions
/// (0xFE), plain-text extensions (0x01), and application extensions (0xFF)
/// other than the standard NETSCAPE2.0/ANIMEXTS1.0 looping ones. The relay
/// rejects all of them (`MetadataForbidden` in buzz-media's
/// `validate_gif_metadata_free`), and encoders like Photoshop and Giphy emit
/// them routinely, so uploads fail unless the client drops them first.
///
/// Everything the relay accepts — header, colour tables, graphic-control
/// extensions, image descriptors and frame data, standard looping extensions —
/// is copied verbatim, so animation timing, disposal, and pixel data stay
/// byte-identical. Trailing bytes after the trailer (another relay reject) are
/// truncated. Returns `None` when the payload isn't structurally parseable as
/// GIF; the caller then uploads the original bytes and the relay's validator
/// remains the authority.
pub(crate) fn strip_gif_metadata(body: &[u8]) -> Option<Vec<u8>> {
if !(body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a")) || body.len() < 13 {
return None;
}
let packed = body[10];
let mut i = 13usize;
if packed & 0x80 != 0 {
let table_len = 3usize << ((packed & 0x07) as usize + 1);
i = i.checked_add(table_len).filter(|&end| end <= body.len())?;
}
let mut out = Vec::with_capacity(body.len());
out.extend_from_slice(&body[..i]);
loop {
match *body.get(i)? {
// Image descriptor: optional local colour table, LZW minimum code
// size, then image-data sub-blocks. Copied verbatim.
0x2c => {
if i + 10 > body.len() {
return None;
}
let image_packed = body[i + 9];
let mut end = i + 10;
if image_packed & 0x80 != 0 {
let table_len = 3usize << ((image_packed & 0x07) as usize + 1);
end = end.checked_add(table_len).filter(|&e| e <= body.len())?;
}
end = end.checked_add(1).filter(|&e| e <= body.len())?;
end = gif_sub_blocks_end(body, end)?;
out.extend_from_slice(&body[i..end]);
i = end;
}
0x21 => {
let label = *body.get(i + 1)?;
let start = i;
i += 2;
match label {
// Graphic Control Extension: fixed-shape rendering state
// (delay, disposal, transparency). Kept verbatim.
0xf9 => {
if body.get(i) != Some(&4) || i + 6 > body.len() || body[i + 5] != 0 {
return None;
}
i += 6;
out.extend_from_slice(&body[start..i]);
}
// Application extension: keep only the standard looping
// extensions; anything else (XMP, Photoshop, Giphy…) is a
// metadata channel and is dropped.
0xff => {
if body.get(i) != Some(&11) || i + 12 > body.len() {
return None;
}
let app = &body[i + 1..i + 12];
let keep = app == b"NETSCAPE2.0" || app == b"ANIMEXTS1.0";
i = gif_sub_blocks_end(body, i + 12)?;
if keep {
out.extend_from_slice(&body[start..i]);
}
}
// Comment (0xFE), plain-text (0x01), and unknown
// extensions: pure metadata channels, dropped. Their
// bodies are all length-prefixed sub-block sequences
// (plain-text's 12-byte header is itself a sub-block).
_ => {
i = gif_sub_blocks_end(body, i)?;
}
}
}
// Trailer: emit and stop, truncating any trailing bytes.
0x3b => {
out.push(0x3b);
return Some(out);
}
_ => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::media::sanitize_image_for_upload;
/// Minimal single-frame GIF89a: header, 2×2 logical screen, 2-entry global
/// colour table, NETSCAPE looping extension, graphic control extension,
/// one image descriptor with LZW data, trailer. Structurally canonical —
/// the relay's validator accepts exactly this shape.
fn minimal_gif() -> Vec<u8> {
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&[
0x02, 0x00, 0x02, 0x00, // logical screen 2×2
0x80, 0x00, 0x00, // GCT flag, 2 entries
0x00, 0x00, 0x00, 0xff, 0xff, 0xff, // colour table
]);
// NETSCAPE2.0 looping application extension.
gif.extend_from_slice(&[0x21, 0xff, 11]);
gif.extend_from_slice(b"NETSCAPE2.0");
gif.extend_from_slice(&[3, 0x01, 0x00, 0x00, 0x00]);
// Graphic control extension.
gif.extend_from_slice(&[0x21, 0xf9, 4, 0x00, 0x0a, 0x00, 0x00, 0x00]);
// Image descriptor + 2-bit LZW data.
gif.extend_from_slice(&[0x2c, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00]);
gif.extend_from_slice(&[0x02, 0x02, 0x44, 0x01, 0x00]);
gif.push(0x3b);
gif
}
/// Comment extension: `0x21 0xFE`, one data sub-block, terminator.
fn gif_comment_ext() -> Vec<u8> {
let mut ext = vec![0x21, 0xfe, 5];
ext.extend_from_slice(b"hello");
ext.push(0);
ext
}
/// XMP-style application extension (what Photoshop/Giphy emit).
fn gif_xmp_ext() -> Vec<u8> {
let mut ext = vec![0x21, 0xff, 11];
ext.extend_from_slice(b"XMP DataXMP");
ext.extend_from_slice(&[4]);
ext.extend_from_slice(b"<x/>");
ext.push(0);
ext
}
#[test]
fn test_strip_gif_metadata_removes_comment_and_foreign_app_extensions() {
let clean = minimal_gif();
// Splice metadata extensions after the global colour table (offset 19).
let mut dirty = clean[..19].to_vec();
dirty.extend_from_slice(&gif_comment_ext());
dirty.extend_from_slice(&gif_xmp_ext());
dirty.extend_from_slice(&clean[19..]);
let stripped = strip_gif_metadata(&dirty).unwrap();
assert_eq!(stripped, clean);
}
#[test]
fn test_strip_gif_metadata_preserves_clean_gif_byte_identical() {
let clean = minimal_gif();
assert_eq!(strip_gif_metadata(&clean).unwrap(), clean);
}
#[test]
fn test_strip_gif_metadata_truncates_bytes_after_trailer() {
let mut padded = minimal_gif();
padded.extend_from_slice(b"junk after trailer");
assert_eq!(strip_gif_metadata(&padded).unwrap(), minimal_gif());
}
#[test]
fn test_strip_gif_metadata_rejects_unparseable_payloads() {
// Not a GIF at all.
assert!(strip_gif_metadata(b"GIF89a").is_none());
assert!(strip_gif_metadata(b"\x89PNG\r\n\x1a\n").is_none());
// Truncated mid-structure.
let clean = minimal_gif();
assert!(strip_gif_metadata(&clean[..clean.len() - 3]).is_none());
}
#[test]
fn test_sanitize_gif_strips_metadata_and_passes_through_unparseable() {
let clean = minimal_gif();
let mut dirty = clean[..19].to_vec();
dirty.extend_from_slice(&gif_comment_ext());
dirty.extend_from_slice(&clean[19..]);
assert_eq!(
sanitize_image_for_upload(dirty, "image/gif").unwrap(),
clean
);
// Unparseable GIF payloads pass through unchanged — the relay's
// validator stays the authority.
let junk = b"GIF89a\x00\x01".to_vec();
assert_eq!(
sanitize_image_for_upload(junk.clone(), "image/gif").unwrap(),
junk
);
}
}
+1
View File
@@ -24,6 +24,7 @@ mod legacy_storage;
mod link_preview;
pub(crate) mod media;
mod media_download;
mod media_gif;
mod media_transcode;
#[cfg(feature = "mesh-llm")]
pub(crate) mod mesh_llm;