[codex] Support web document uploads (#2332)

* fix: support web document uploads

* fix(web): address zmanian review — MIME allowlist, rename ImageData, update spec (#2332)

- Add server-side MIME type allowlist in uploads_to_attachments() to reject
  unsafe file types (executables, scripts, HTML). Accepts: image/*, audio/*,
  PDF, plain text, CSV, Markdown, JSON, XML, RTF, and Office documents.
- Rename ImageData → AttachmentData since it now carries any file type.
- Update web channel CLAUDE.md spec: body limit is 16 MB (not 10 MB),
  document supported upload types and MIME rejection behavior.
- Clarify JS file size constants with comment explaining why two exist.
- Add regression tests for MIME allowlist (reject + accept paths).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): server-side magic-byte sniffing + fix JS file staging race (#2332)

Address henrypark133 review: MIME allowlist was validating the client-
supplied media_type field, not actual bytes. Add validate_content_matches_
claimed_type() that checks magic bytes for binary formats (PDF, PNG,
JPEG, GIF, WebP, ZIP/Office, OLE2/Office, RTF, MP3, OGG, WAV) and
UTF-8 validity for text/* claims. Called after base64 decode in
uploads_to_attachments().

Address gemini-code-assist review: handleAttachmentFiles() had a race
condition where stagedBytes/stagedCount were computed from stagedFiles,
but stagedFiles was only updated in the async FileReader.onload callback.
Rapid concurrent calls could bypass MAX_STAGED_FILES and
MAX_TOTAL_FILE_SIZE_BYTES limits. Fix by pushing a placeholder entry
to stagedFiles synchronously (with loading:true), then filling in
data/dataUrl in the callback. Send path blocks while files are loading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): address zmanian review — harden upload MIME validation (#2332)

* fix(web): correct upload test payloads, tighten ADTS, clear clippy warnings

- Integration tests: use `mime_type`/`data_base64` to match `AttachmentData`
  wire shape so both caller-level tests exercise the validation path they
  claim to (previously failed with 422 before reaching the handler).
- Tighten `audio/aac` magic-byte check with mask 0xF6 so MP3 frames
  mis-declared as AAC are rejected; accept ADIF as fallback.
- Refactor `validate_content_matches_claimed_type` into match-with-guards,
  clearing 13 new `clippy::collapsible_match` warnings.
- Add `debug_assert!` guard where allow-list and extension map must stay
  in sync; fallback stays for defence-in-depth.
- Regression tests: PDF-body mismatch, MP3-spoofed-as-AAC, valid ADTS AAC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
This commit is contained in:
firat.sertgoz
2026-04-20 20:58:29 +03:00
committed by GitHub
parent ae9b179560
commit e35099de23
6 changed files with 451 additions and 52 deletions

View File

@@ -282,7 +282,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Audio transcription | ✅ | ❌ | P2 | |
| Video support | ✅ | ❌ | P3 | |
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
| PDF parsing | ✅ | | P2 | `pdfjs-dist` fallback path |
| PDF parsing | ✅ | 🚧 | P2 | Uploaded document attachments parse via `pdf-extract`; no `pdfjs-dist` fallback path |
| MIME detection | ✅ | ❌ | P2 | |
| Media caching | ✅ | ❌ | P3 | |
| Vision model integration | ✅ | ❌ | P2 | Image understanding |

View File

@@ -257,7 +257,7 @@
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<div id="ghost-text" class="ghost-text"></div>
</div>
<input type="file" id="image-file-input" accept="image/*,.pdf,.txt,.md,.csv,.json,.xml,.ppt,.pptx,.docx,.xlsx" multiple style="display:none">
<input type="file" id="image-file-input" accept="image/*,audio/*,.pdf,.txt,.md,.csv,.json,.xml,.rtf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.ogg,.wav,.m4a,.mp4,.aac,.flac,.webm" multiple style="display:none">
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachFiles" data-i18n-attr="title" title="Attach files"
aria-label="Attach files">&#x1F4CE;</button>
<button id="send-btn" data-i18n="chat.send">Send</button>

View File

@@ -102,6 +102,11 @@ let stagedAttachments = [];
// array before composing the body so an Enter-press during file decode still
// includes the attachment.
const pendingAttachmentReads = [];
// Reserved attachment budget for files accepted into FileReader but not yet
// materialized in `stagedAttachments`. This closes race windows across rapid
// repeated attach/drop actions before async reads complete.
let pendingAttachmentBytes = 0;
let pendingAttachmentCount = 0;
let authFlowPending = false;
// Tracks user messages sent but not yet persisted to DB (#2409).
// When loadHistory() clears the DOM, pending messages are re-injected

View File

@@ -545,12 +545,23 @@ function inferAttachmentMimeType(file) {
if (name.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
if (name.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
if (name.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (name.endsWith('.doc')) return 'application/msword';
if (name.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (name.endsWith('.xls')) return 'application/vnd.ms-excel';
if (name.endsWith('.md')) return 'text/markdown';
if (name.endsWith('.csv')) return 'text/csv';
if (name.endsWith('.json')) return 'application/json';
if (name.endsWith('.xml')) return 'application/xml';
if (name.endsWith('.rtf')) return 'application/rtf';
if (name.endsWith('.txt')) return 'text/plain';
if (name.endsWith('.mp3')) return 'audio/mpeg';
if (name.endsWith('.ogg')) return 'audio/ogg';
if (name.endsWith('.wav')) return 'audio/wav';
if (name.endsWith('.m4a')) return 'audio/x-m4a';
if (name.endsWith('.mp4')) return 'audio/mp4';
if (name.endsWith('.aac')) return 'audio/aac';
if (name.endsWith('.flac')) return 'audio/flac';
if (name.endsWith('.webm')) return 'audio/webm';
return 'application/octet-stream';
}
@@ -628,8 +639,8 @@ const MAX_TOTAL_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10 MB decoded per messag
const MAX_STAGED_ATTACHMENTS = 5;
function handleAttachmentFiles(files) {
let projectedCount = stagedAttachments.length;
let projectedTotalBytes = stagedAttachments.reduce((sum, att) => sum + (att.size_bytes || 0), 0);
let projectedCount = stagedAttachments.length + pendingAttachmentCount;
let projectedTotalBytes = stagedAttachments.reduce((sum, att) => sum + (att.size_bytes || 0), 0) + pendingAttachmentBytes;
Array.from(files).forEach(file => {
const mimeType = inferAttachmentMimeType(file);
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
@@ -646,11 +657,16 @@ function handleAttachmentFiles(files) {
}
projectedCount += 1;
projectedTotalBytes += file.size;
pendingAttachmentCount += 1;
pendingAttachmentBytes += file.size;
const reader = new FileReader();
let resolveRead;
const readPromise = new Promise((resolve) => { resolveRead = resolve; });
pendingAttachmentReads.push(readPromise);
const finalizeRead = () => {
pendingAttachmentCount = Math.max(0, pendingAttachmentCount - 1);
pendingAttachmentBytes = Math.max(0, pendingAttachmentBytes - file.size);
const idx = pendingAttachmentReads.indexOf(readPromise);
if (idx !== -1) pendingAttachmentReads.splice(idx, 1);
resolveRead();

View File

@@ -11,56 +11,190 @@ use crate::generated_images::GeneratedImageSentinel;
pub use ironclaw_common::truncate_preview;
/// Convert web gateway `ImageData` to `IncomingAttachment` objects.
pub(crate) fn images_to_attachments(
images: &[ImageData],
) -> Vec<crate::channels::IncomingAttachment> {
use base64::Engine;
images
.iter()
.enumerate()
.filter_map(|(i, img)| {
if !img.media_type.starts_with("image/") {
tracing::warn!(
"Skipping image {i}: invalid media type '{}' (must start with 'image/')",
img.media_type
);
return None;
}
let data = match base64::engine::general_purpose::STANDARD.decode(&img.data) {
Ok(d) => d,
Err(e) => {
tracing::warn!("Skipping image {i}: invalid base64 data: {e}");
return None;
}
};
Some(crate::channels::IncomingAttachment {
id: format!("web-image-{i}"),
kind: crate::channels::AttachmentKind::Image,
mime_type: img.media_type.clone(),
filename: Some(format!("image-{i}.{}", mime_to_ext(&img.media_type))),
size_bytes: Some(data.len() as u64),
source_url: None,
storage_key: None,
local_path: None,
extracted_text: None,
data,
duration_secs: None,
})
})
.collect()
fn normalize_mime_type(mime: &str) -> String {
mime.split(';')
.next()
.unwrap_or(mime)
.trim()
.to_ascii_lowercase()
}
fn mime_to_ext(mime: &str) -> &str {
fn has_riff_fourcc(data: &[u8], fourcc: &[u8; 4]) -> bool {
data.len() >= 12 && data.starts_with(b"RIFF") && data.get(8..12) == Some(fourcc)
}
fn has_iso_bmff_ftyp(data: &[u8]) -> bool {
data.len() >= 8 && data.get(4..8) == Some(b"ftyp")
}
fn image_mime_to_ext(mime: &str) -> &'static str {
match mime {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
_ => "jpg",
}
}
fn web_attachment_ext(mime: &str) -> Option<&'static str> {
let ext = match mime {
"image/png" => Some("png"),
"image/jpeg" | "image/jpg" => Some("jpg"),
"image/gif" => Some("gif"),
"image/webp" => Some("webp"),
"application/pdf" => Some("pdf"),
"text/plain" => Some("txt"),
"text/markdown" => Some("md"),
"text/csv" => Some("csv"),
"application/json" => Some("json"),
"application/xml" | "text/xml" => Some("xml"),
"application/rtf" | "text/rtf" => Some("rtf"),
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
"application/vnd.ms-powerpoint" => Some("ppt"),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Some("docx"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Some("xlsx"),
"application/msword" => Some("doc"),
"application/vnd.ms-excel" => Some("xls"),
"audio/mpeg" => Some("mp3"),
"audio/ogg" => Some("ogg"),
"audio/wav" | "audio/wave" | "audio/x-wav" => Some("wav"),
"audio/mp4" => Some("mp4"),
"audio/x-m4a" => Some("m4a"),
"audio/aac" => Some("aac"),
"audio/flac" => Some("flac"),
"audio/webm" => Some("webm"),
_ => None,
};
if ext.is_none() {
tracing::warn!(
mime_type = mime,
"Unknown upload MIME type missing default extension"
);
}
ext
}
fn is_allowed_legacy_image_mime(mime: &str) -> bool {
matches!(
mime,
"image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp"
)
}
fn is_allowed_attachment_mime(mime: &str) -> bool {
matches!(
mime,
"image/png"
| "image/jpeg"
| "image/jpg"
| "image/gif"
| "image/webp"
| "audio/mpeg"
| "audio/ogg"
| "audio/wav"
| "audio/wave"
| "audio/x-wav"
| "audio/mp4"
| "audio/x-m4a"
| "audio/aac"
| "audio/flac"
| "audio/webm"
| "text/plain"
| "text/csv"
| "text/markdown"
| "text/xml"
| "application/pdf"
| "application/json"
| "application/xml"
| "application/rtf"
| "text/rtf"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
| "application/msword"
| "application/vnd.ms-powerpoint"
| "application/vnd.ms-excel"
)
}
fn validate_content_matches_claimed_type(claimed: &str, data: &[u8]) -> Result<(), String> {
if claimed.starts_with("text/") || claimed == "application/json" || claimed == "application/xml"
{
if std::str::from_utf8(data).is_err() {
return Err(format!(
"File claimed as {claimed} but contains invalid UTF-8 — not a text file"
));
}
return Ok(());
}
// ADTS sync for AAC: byte[0] = 0xFF, byte[1] = 1111_ID LL P (LL = layer bits,
// must be 00 for AAC). Mask 0xF6 = sync-bits + layer-bits; expected 0xF0. MP3
// frames share the 0xFFF sync but use layer != 00, so the mask distinguishes.
let aac_is_adts = data.len() >= 2 && data[0] == 0xFF && (data[1] & 0xF6) == 0xF0;
let mp3_sync = data.starts_with(b"ID3")
|| (data.len() >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0);
match claimed {
"application/pdf" if !data.starts_with(b"%PDF") => {
Err("File claimed as application/pdf but does not start with %PDF header".to_string())
}
"image/png" if !data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) => {
Err("File claimed as image/png but missing PNG header".to_string())
}
"image/jpeg" | "image/jpg" if !data.starts_with(&[0xFF, 0xD8, 0xFF]) => {
Err("File claimed as image/jpeg but missing JPEG header".to_string())
}
"image/gif" if !data.starts_with(b"GIF87a") && !data.starts_with(b"GIF89a") => {
Err("File claimed as image/gif but missing GIF header".to_string())
}
"image/webp" if !has_riff_fourcc(data, b"WEBP") => {
Err("File claimed as image/webp but missing RIFF/WEBP header".to_string())
}
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if !data.starts_with(&[0x50, 0x4B, 0x03, 0x04]) =>
{
Err(format!(
"File claimed as {claimed} but missing ZIP/PK header"
))
}
"application/msword" | "application/vnd.ms-powerpoint" | "application/vnd.ms-excel"
if !data.starts_with(&[0xD0, 0xCF, 0x11, 0xE0]) =>
{
Err(format!("File claimed as {claimed} but missing OLE2 header"))
}
"application/rtf" | "text/rtf" if !data.starts_with(b"{\\rtf") => {
Err("File claimed as RTF but missing {\\rtf header".to_string())
}
"audio/mpeg" if !mp3_sync => {
Err("File claimed as audio/mpeg but missing MP3/ID3 header".to_string())
}
"audio/ogg" if !data.starts_with(b"OggS") => {
Err("File claimed as audio/ogg but missing OggS header".to_string())
}
"audio/wav" | "audio/wave" | "audio/x-wav" if !has_riff_fourcc(data, b"WAVE") => {
Err("File claimed as audio/wav but missing RIFF/WAVE header".to_string())
}
"audio/mp4" | "audio/x-m4a" if !has_iso_bmff_ftyp(data) => {
Err("File claimed as audio/mp4 but missing ISO BMFF ftyp header".to_string())
}
"audio/aac" if !aac_is_adts && !data.starts_with(b"ADIF") => {
Err("File claimed as audio/aac but missing ADTS/ADIF header".to_string())
}
"audio/flac" if !data.starts_with(b"fLaC") => {
Err("File claimed as audio/flac but missing fLaC header".to_string())
}
"audio/webm" if !data.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) => {
Err("File claimed as audio/webm but missing EBML header".to_string())
}
_ => Ok(()),
}
}
fn normalize_attachment_filename(filename: &str) -> Option<&str> {
let trimmed = filename.trim();
if trimmed.is_empty() {
@@ -70,14 +204,47 @@ fn normalize_attachment_filename(filename: &str) -> Option<&str> {
}
}
fn attachment_ext(mime: &str) -> &str {
crate::channels::attachment_extension_for_mime(mime)
/// Convert web gateway `ImageData` to `IncomingAttachment` objects.
pub(crate) fn images_to_attachments(
images: &[ImageData],
) -> Result<Vec<crate::channels::IncomingAttachment>, String> {
use base64::Engine;
images
.iter()
.enumerate()
.map(|(i, img)| {
let normalized_mime = normalize_mime_type(&img.media_type);
if !is_allowed_legacy_image_mime(&normalized_mime) {
return Err(format!(
"Unsupported image type: {}. Allowed image types: PNG, JPEG, GIF, and WebP.",
img.media_type
));
}
let data = base64::engine::general_purpose::STANDARD
.decode(&img.data)
.map_err(|e| format!("Invalid image {i}: base64 decode failed: {e}"))?;
validate_content_matches_claimed_type(&normalized_mime, &data)?;
Ok(crate::channels::IncomingAttachment {
id: format!("web-image-{i}"),
kind: crate::channels::AttachmentKind::Image,
mime_type: normalized_mime.clone(),
filename: Some(format!("image-{i}.{}", image_mime_to_ext(&normalized_mime))),
size_bytes: Some(data.len() as u64),
source_url: None,
storage_key: None,
local_path: None,
extracted_text: None,
data,
duration_secs: None,
})
})
.collect()
}
/// Convert web gateway `AttachmentData` (generic file upload) to
/// `IncomingAttachment` objects. Unlike `images_to_attachments`, this path is
/// strict: a malformed base64 payload is surfaced as an error to the caller so
/// the client gets a concrete rejection instead of a silent drop.
/// strict: malformed base64 or spoofed MIME payloads are surfaced as concrete
/// errors to the caller so the client gets a clear rejection.
pub(crate) fn web_attachments_to_incoming(
attachments: &[AttachmentData],
) -> Result<Vec<crate::channels::IncomingAttachment>, String> {
@@ -86,21 +253,41 @@ pub(crate) fn web_attachments_to_incoming(
.iter()
.enumerate()
.map(|(i, attachment)| {
let normalized_mime = normalize_mime_type(&attachment.mime_type);
if !is_allowed_attachment_mime(&normalized_mime) {
return Err(format!(
"Unsupported file type: {}. Allowed types: PNG/JPEG/GIF/WebP images; MP3/Ogg/WAV/AAC/FLAC/MP4/M4A/WebM audio; PDF; plain text; CSV; Markdown; JSON; XML; RTF; and Office documents.",
attachment.mime_type
));
}
let data = base64::engine::general_purpose::STANDARD
.decode(&attachment.data_base64)
.map_err(|e| format!("Invalid attachment {i}: base64 decode failed: {e}"))?;
validate_content_matches_claimed_type(&normalized_mime, &data)?;
let filename = attachment
.filename
.as_deref()
.and_then(normalize_attachment_filename)
.map(str::to_owned)
.unwrap_or_else(|| {
format!("attachment-{i}.{}", attachment_ext(&attachment.mime_type))
// `is_allowed_attachment_mime` and `web_attachment_ext` are the
// same set by construction, so the extension is always resolvable.
// The `None` fallback is defence-in-depth: if the two lists ever
// drift, we emit an extensionless filename rather than panicking
// in production.
debug_assert!(
web_attachment_ext(&normalized_mime).is_some(),
"allow-list and extension map diverged for MIME {normalized_mime}"
);
match web_attachment_ext(&normalized_mime) {
Some(ext) => format!("attachment-{i}.{ext}"),
None => format!("attachment-{i}"),
}
});
Ok(crate::channels::IncomingAttachment {
id: format!("web-attachment-{i}"),
kind: crate::channels::AttachmentKind::from_mime_type(&attachment.mime_type),
mime_type: attachment.mime_type.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&normalized_mime),
mime_type: normalized_mime,
filename: Some(filename),
size_bytes: Some(data.len() as u64),
source_url: None,
@@ -156,7 +343,7 @@ pub(crate) fn inline_attachments_to_incoming(
) -> Result<Vec<crate::channels::IncomingAttachment>, String> {
let mut incoming = web_attachments_to_incoming(attachments)?;
if !images.is_empty() {
incoming.extend(images_to_attachments(images));
incoming.extend(images_to_attachments(images)?);
}
validate_inline_attachment_budget(&incoming)?;
Ok(incoming)
@@ -961,4 +1148,116 @@ mod tests {
assert!(turns[1].generated_images[0].data_url.is_some());
assert!(turns[1].generated_images[1].data_url.is_some());
}
#[test]
fn web_upload_normalizes_attachment_mime_case() {
use base64::Engine;
let attachments = vec![AttachmentData {
mime_type: "Application/PDF; Charset=UTF-8".to_string(),
filename: None,
data_base64: base64::engine::general_purpose::STANDARD.encode(b"%PDF-1.7\n"),
}];
let incoming =
web_attachments_to_incoming(&attachments).expect("uppercase MIME should pass");
assert_eq!(incoming[0].mime_type, "application/pdf");
assert_eq!(incoming[0].filename.as_deref(), Some("attachment-0.pdf"));
}
#[test]
fn web_upload_rejects_svg_attachment() {
use base64::Engine;
let attachments = vec![AttachmentData {
mime_type: "image/svg+xml".to_string(),
filename: None,
data_base64: base64::engine::general_purpose::STANDARD
.encode(br#"<svg xmlns='http://www.w3.org/2000/svg'></svg>"#),
}];
let err = web_attachments_to_incoming(&attachments).unwrap_err();
assert!(err.contains("Unsupported file type"));
}
#[test]
fn web_upload_rejects_spoofed_audio_mp4() {
use base64::Engine;
let attachments = vec![AttachmentData {
mime_type: "audio/mp4".to_string(),
filename: Some("voice.m4a".to_string()),
data_base64: base64::engine::general_purpose::STANDARD.encode(b"not-an-mp4"),
}];
let err = web_attachments_to_incoming(&attachments).unwrap_err();
assert!(err.contains("missing ISO BMFF ftyp header"));
}
#[test]
fn web_upload_rejects_svg_legacy_image() {
use base64::Engine;
let images = vec![ImageData {
media_type: "image/svg+xml".to_string(),
data: base64::engine::general_purpose::STANDARD
.encode(br#"<svg xmlns='http://www.w3.org/2000/svg'></svg>"#),
}];
let err = images_to_attachments(&images).unwrap_err();
assert!(err.contains("Unsupported image type"));
}
#[test]
fn web_upload_rejects_pdf_with_non_pdf_body() {
use base64::Engine;
let attachments = vec![AttachmentData {
mime_type: "application/pdf".to_string(),
filename: Some("invoice.pdf".to_string()),
data_base64: base64::engine::general_purpose::STANDARD
.encode(b"<html>not a pdf</html>"),
}];
let err = web_attachments_to_incoming(&attachments).unwrap_err();
assert!(
err.contains("%PDF"),
"expected %PDF-header rejection, got: {err}"
);
}
#[test]
fn web_upload_rejects_mp3_spoofed_as_aac() {
use base64::Engine;
// Layer III MP3 frame: byte[0]=0xFF, byte[1]=0xFB (1111_1011). Layer bits
// (mask 0x06) = 0b10 != 0b00, so ADTS check rejects it. Sync-only check
// (byte[1] & 0xF0 == 0xF0) would incorrectly accept.
let attachments = vec![AttachmentData {
mime_type: "audio/aac".to_string(),
filename: Some("song.aac".to_string()),
data_base64: base64::engine::general_purpose::STANDARD.encode([0xFFu8, 0xFB, 0, 0]),
}];
let err = web_attachments_to_incoming(&attachments).unwrap_err();
assert!(
err.contains("ADTS/ADIF"),
"expected ADTS/ADIF rejection, got: {err}"
);
}
#[test]
fn web_upload_accepts_valid_adts_aac() {
use base64::Engine;
// Valid ADTS: byte[0]=0xFF, byte[1]=0xF1 (1111_0001: sync+ID=0+layer=00+P=1).
let attachments = vec![AttachmentData {
mime_type: "audio/aac".to_string(),
filename: Some("song.aac".to_string()),
data_base64: base64::engine::general_purpose::STANDARD.encode([0xFFu8, 0xF1, 0, 0]),
}];
let incoming = web_attachments_to_incoming(&attachments).expect("valid ADTS should pass");
assert_eq!(incoming[0].mime_type, "audio/aac");
}
}

View File

@@ -785,6 +785,85 @@ async fn full_server_chat_send_accepted_for_alice() {
assert_eq!(msg.channel, "gateway");
}
#[tokio::test]
async fn full_server_chat_send_accepts_document_attachment_for_alice() {
let (agent_tx, mut agent_rx) = tokio::sync::mpsc::channel(64);
let auth = two_user_auth();
let (addr, _state) = TestGatewayBuilder::new()
.msg_tx(agent_tx)
.start_multi(auth)
.await
.expect("Failed to start server");
let client = reqwest::Client::new();
let resp = client
.post(format!("http://{}/api/chat/send", addr))
.header("Authorization", format!("Bearer {}", ALICE_TOKEN))
.json(&serde_json::json!({
"content": "parse this invoice",
"attachments": [{
"mime_type": "application/pdf",
"filename": "invoice.pdf",
"data_base64": "JVBERi0xLjQKaW52b2ljZQ=="
}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 202);
let msg = tokio::time::timeout(Duration::from_secs(2), agent_rx.recv())
.await
.expect("Timed out waiting for agent message")
.expect("Agent channel closed");
assert_eq!(msg.content, "parse this invoice");
assert_eq!(msg.attachments.len(), 1);
assert_eq!(
msg.attachments[0].kind,
ironclaw::channels::AttachmentKind::Document
);
assert_eq!(msg.attachments[0].mime_type, "application/pdf");
assert_eq!(msg.attachments[0].filename.as_deref(), Some("invoice.pdf"));
assert_eq!(msg.attachments[0].data, b"%PDF-1.4\ninvoice");
}
#[tokio::test]
async fn full_server_chat_send_rejects_malformed_attachment_for_alice() {
let (agent_tx, mut agent_rx) = tokio::sync::mpsc::channel(64);
let auth = two_user_auth();
let (addr, _state) = TestGatewayBuilder::new()
.msg_tx(agent_tx)
.start_multi(auth)
.await
.expect("Failed to start server");
let client = reqwest::Client::new();
let resp = client
.post(format!("http://{}/api/chat/send", addr))
.header("Authorization", format!("Bearer {}", ALICE_TOKEN))
.json(&serde_json::json!({
"content": "parse this invoice",
"attachments": [{
"mime_type": "application/pdf",
"filename": "invoice.pdf",
"data_base64": "not valid base64"
}]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(
tokio::time::timeout(Duration::from_millis(100), agent_rx.recv())
.await
.is_err(),
"Malformed uploads must not queue a text-only agent message"
);
}
#[tokio::test]
async fn full_server_chat_send_rewrites_sender_only_for_owner_scope_rebind() {
let (addr, _state, mut agent_rx) = start_owner_scoped_sender_server().await;