fix(media): validate Content-Type and magic bytes before sending to model#793
fix(media): validate Content-Type and magic bytes before sending to model#793howie wants to merge 9 commits into
Conversation
…odel Fixes openabdev#776. When a Slack bot token lacks the `files:read` OAuth scope, Slack serves the workspace login HTML page (~55 KB) at HTTP 200 with a `text/html` Content-Type instead of the requested file binary. `download_and_encode_image` previously accepted this response because: 1. It never inspected the HTTP response `Content-Type` header. 2. On `resize_and_compress` failure for a body ≤ 1 MB it fell back to forwarding the raw bytes under the Slack-reported MIME (`image/png`), bypassing any format check. The result: a `ContentBlock::Image { media_type: "image/png", data: <base64 HTML> }` flowed through to Anthropic, which 400'd with "Could not process image". Because claude-agent-acp persists the user message into the session JSONL before the API reply, the bad block replayed on every subsequent turn in that Slack thread until an operator manually deleted the JSONL inside the pod. Changes: - Add `MediaFetchError` enum to `src/media.rs` so callers can distinguish "not an image, skip silently" (`NotAnImage`) from "claimed image, got unexpected bytes" (`UnsupportedResponseType`, `InvalidImageBody`). - Add `validate_image_response(content_type, body)` pure helper that: - Rejects any HTTP response whose Content-Type (stripped of params, lowercased) is not in `{image/png, image/jpeg, image/gif, image/webp}`. - Sniffs magic bytes via `image::ImageReader::with_guessed_format()` (no new dependencies) and rejects anything that doesn't decode as one of the four supported formats. - Change `download_and_encode_image` signature from `-> Option<ContentBlock>` to `-> Result<ContentBlock, MediaFetchError>`, capturing the Content-Type header before consuming the response with `.bytes()`. - Remove the ≤ 1 MB resize-error fallback that was the direct bug path. - Update `src/slack.rs` call site: on validation failure, collect filenames and post one aggregated user-visible warning to the Slack thread: ":warning: I couldn't access the file(s) you shared (`<name>`). This often means the bot is missing the `files:read` OAuth scope. Please ask an admin to reinstall the app with that scope." - Update `src/discord.rs` call site: `warn!` log on failure (Discord URLs are signed-public so the Slack scope hint is not applicable there). Preserve the existing `is_video_file` fallback for `Err(NotAnImage)`. - Add 12 unit tests for `validate_image_response` including the exact bug repro case (HTML body labeled `image/png`, first 8 bytes `3c21444f43545950`). Out of scope / follow-up issues: - Secondary defense: deferring claude-agent-acp JSONL persistence until after model returns 200 (requires changes in the claude-agent-acp Node project). - Startup preflight calling Slack `auth.test` to warn loudly on missing scopes. - Same Content-Type/magic-byte hardening for `download_and_transcribe` and `download_and_read_text_file`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Remove dead hinted field from UnsupportedResponseType (always None) - Eliminate double reader.format() call with fmt@ binding - Deduplicate hex_prefix() in resize error path (compute once, reuse) - Promote strip_mime_params to media::strip_mime_params (pub crate), slack.rs delegates to it -- single source of truth for MIME stripping Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Critical: change Content-Type check from allow-list to block-list (Codex finding). The allow-list rejected application/octet-stream before magic-byte check ran, silently dropping valid images from CDNs. Only text/* is now rejected early; everything else falls through to magic-byte verification. Also: - Soften Slack warning message: no longer attributes all failures to files:read scope; now mentions format support as a second cause - Add SizeExceeded to Slack user notification (was silent) - Log failures from send_message() instead of using let _ = - Log discarded io::Error from with_guessed_format - Fix doc comments: download_and_encode_image (SizeExceeded fires pre-HTTP), validate_image_response (Content-Type check short-circuits, not sequential) - Replace inline "Validate Content-Type..." comment with WHY explanation - Restore doc comment on strip_mime_params wrapper in slack.rs - Add tests: octet-stream acceptance (Codex regression fix), JSON body rejection by magic bytes, missing Content-Type + invalid body Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex adversarial review found that user-controlled filenames embedded in the mrkdwn warning message could inject Slack markup (backtick break-out, <!here> mentions, <@uid> pings). Replace backticks and angle brackets with safe ASCII equivalents before embedding in the message. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex Challenge Report — Adversarial ReviewFinding 1: Slack filename mrkdwn injection [FIXED]Filenames embedded in the Slack warning message were user-controlled. A filename containing backticks, Finding 2: Corrupt GIF bodies pass magic-byte check [Known Issue — not in scope]GIF format is detected by magic bytes ( This is pre-existing behavior from before this PR. Fixing it would require decoding GIF frames for validation, which risks breaking animated GIF support. Filed as a known limitation; a follow-up PR should add frame-count validation for GIFs. Finding 3: failed_image_files Vec is unbounded per event [Acceptable]The Vec is bounded by Slack's own message attachment limit (~20 files). Not a persistent leak. Acceptable for now. No TOCTOU between Content-Type capture and body readHeaders and body come from the same immutable hex_prefix cannot panicUses Mixed success: one valid PNG + one HTML file in same messageValid PNG → pushed to Generated by /pr-review-cycle-codex Step 8 — Codex adversarial challenge |
|
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1491969620754567270/1503586535088590868 |
OpenAB PR-Screening ReportThis was generated by an agent-run OpenAB PR-screening workflow. Feedback is welcome; a 👍 reaction is useful if this format helped. IntentPR #793 fixes a Slack media-ingestion failure where OpenAB could fetch non-image bytes from Slack, label them as an image, and forward them to the model. The user-visible problem is a confusing Anthropic image-processing error; the operator-visible problem is worse: a bad image block can be persisted into the agent session history and poison later turns in the same Slack thread. FeatThis is a bug fix and reliability hardening PR. It changes image downloading from a loose Who It ServesPrimary beneficiaries are Slack users and OpenAB operators. Users get actionable feedback instead of a provider-level 400. Operators avoid manual PVC/session cleanup when Slack app scopes are wrong or file downloads return HTML/error pages. Discord users also benefit from the shared validation path, though Discord failures remain log-only because Discord attachment URLs have different auth semantics. Rewritten PromptFix Slack/Discord image ingestion so OpenAB never forwards unverified fetched bytes as model image input. Requirements:
Merge PitchThis PR is worth moving forward because it closes a high-impact ingestion bug with a narrow, well-contained validation layer. The fix prevents a bad Slack fetch from reaching the model, improves user feedback, and reduces the chance of poisoned long-lived sessions. The main reviewer concern should be whether validation is strict enough for corrupted files without breaking legitimate Slack/CDN behavior, especially Best-Practice ComparisonOpenClaw principles relevant here:
Hermes Agent principles relevant here:
The practical lesson from both systems is to treat platform media fetches as untrusted input. Validate at the boundary, fail before model submission, and make recovery possible through normal user/operator action. Implementation OptionsOption A: Conservative Option B: Balanced Option C: Ambitious Comparison Table
RecommendationRecommend moving forward with Option A for this PR, with Option B follow-ups tracked explicitly. The PR directly addresses #776 and includes the right core changes: validate before encoding, remove raw-byte fallback, and show Slack users an actionable warning. The remaining risk is bounded: GIF validation is still weaker than PNG/JPEG/WebP because the current path can accept GIF magic bytes without fully proving the payload is model-usable. That is a good follow-up, not a reason to hold the whole fix if CI is green. Validation attempted:
Project status:
|
This comment has been minimized.
This comment has been minimized.
F1: validate_gif_body now decodes only the first frame instead of
collect_frames() — avoids full in-memory decode of large animated GIFs.
F2: remove duplicate validate_gif_body call from resize_and_compress;
download_and_encode_image already runs validate_image_response before
calling resize, so the second call was redundant.
F3: add MediaFetchError::ProcessingFailed(image::ImageError) for the case
where body passed validation but resize/compress failed — previously
returned the misleading InvalidImageBody variant for a validated image.
F4: extend Slack warning message to mention "file is too large" so the
message is accurate when SizeExceeded failures are included.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Behavior: - slack: add explicit ProcessingFailed arm -> push to failed_image_files and log "post-processing failed" (not "download failed") - slack: extract sanitize_slack_filename() pub(crate) fn; add 4 unit tests for backtick/angle-bracket injection prevention API: - validate_image_response: change return type Result<ImageFormat> -> Result<()> (sole caller only checked Ok/Err; format detection ran twice) Docs: - validate_image_response: add block-list vs allow-list design rationale - validate_gif_body: add doc comment explaining first-frame-only and cursor independence; log original error via debug! before mapping to InvalidImageBody - ProcessingFailed variant: expand doc to clarify semantic difference from InvalidImageBody and expected caller behavior - download_and_encode_image: add ProcessingFailed to error listing Tests: - validate_rejects_mixed_case_text_content_type: pin .to_lowercase() normalization Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chaodu-agent
left a comment
There was a problem hiding this comment.
LGTM ✅ — prior image-validation findings are resolved. Latest head keeps the #776 fix intact, avoids full GIF frame decode, removes duplicate GIF validation, separates post-validation processing failures with ProcessingFailed, and updates the Slack warning to cover size-limit failures. CI is green.
- Restore ≤1MB raw-byte fallback when resize_and_compress fails after validate_image_response passes. The body is confirmed valid by magic-byte check, so forwarding original bytes is safe. Prevents regression for formats the image crate can detect but not fully decode (e.g. animated WebP). - Add HttpStatus 4xx match arm in Slack handler to push filename into failed_image_files. HTTP 4xx (401/403) indicates a persistent permission problem (similar root cause to openabdev#776) and the user should be notified. 5xx and Network errors remain log-only (transient).
CHC-Agent
left a comment
There was a problem hiding this comment.
LGTM ✅ — Verified end-to-end on OrbStack k8s with a real Slack workspace.
Tested scenarios:
- ❌
files:readscope missing → bot correctly rejectstext/htmlresponse (magic bytes3c21444f43545950), logs warning, continues operating. No JSONL poisoning. - ✅
files:readscope present → image downloads, validates, and reaches the model successfully.
Added commit 6d7fd5f with two minor improvements:
- Restore ≤1MB raw-byte fallback when
resize_and_compressfails aftervalidate_image_responsepasses (prevents regression for formats theimagecrate can detect but not fully decode, e.g. animated WebP). - Add
HttpStatus4xx tofailed_image_filesin Slack handler so users are notified on persistent permission errors (not justtext/htmlresponses).
Build passes. Ship it.
🟢 APPROVE (with NITs)Verdict: Solid defensive fix. No blocking issues. Recommend merge after contributor addresses optional NITs. What it solvesWhen a Slack bot token lacks How it solves it
🟢 INFO — well done
🟡 NIT — non-blocking suggestions3 minor suggestions (click to expand)
TestsPR reports 24 media tests passed (13 new). Smoke validation command provided in PR description. Review by 超渡法師 · 四問框架 |
🔴 SUGGESTED CHANGES — blocking regression identifiedUpdate from initial review: 擺渡法師 (Codex) identified a regression in the ≤1MB fallback path that reopens the same class of JSONL poisoning as #776. The problem
// src/media.rs ~L272
Err(e) => {
if bytes.len() <= 1024 * 1024 {
debug!(filename, error = %e, "resize failed, using validated original");
(bytes.to_vec(), mime.to_string()) // ← uses Slack metadata MIME, not detected
} else {
return Err(MediaFetchError::ProcessingFailed(e));
}
}Attack path: A file with a valid PNG signature but truncated/corrupt body (≤1MB) will:
Suggested fix (pick one)Option A (preferred): Remove the ≤1MB raw-byte fallback entirely. If Option B: Make Required regression testAdd a test case: full PNG signature + truncated body (≤1MB) must NOT produce #[test]
fn validate_rejects_truncated_png_body() {
// Valid PNG signature (8 bytes) + IHDR chunk start but truncated
let mut truncated_png = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
truncated_png.extend_from_slice(&[0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]);
// No actual image data — just enough to pass magic-byte detection
let result = validate_image_response(Some("image/png"), &truncated_png);
// This MUST fail — magic bytes pass but body is not decodable
assert!(result.is_err());
}Updated verdict
The 3 NITs from the initial review remain valid but are non-blocking. This regression is blocking. Joint review: 超渡法師 + 擺渡法師 (Codex) |
Additional findings from 普渡法師 (Claude)🔴 F2 —
|
🔴 Consolidated Four-Monk Review — CHANGES REQUESTEDBlocking Issues (must fix before merge)
Required Regression Test#[test]
fn truncated_png_body_must_not_produce_content_block() {
// Valid PNG signature (8 bytes) + partial IHDR but truncated — ≤1MB
let mut truncated = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
truncated.extend_from_slice(&[0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]);
// Must NOT pass validation or produce ContentBlock::Image
let result = validate_image_response(Some("image/png"), &truncated);
assert!(result.is_err());
}Non-blocking NITs (address if convenient)
Reviewers
Four-Monk Collaborative Review · PR Review Practice |
…t reply aligns with warning When download_and_encode_image rejects an attachment, the Slack/Discord adapter already sends the user a visible⚠️ message. Without this change, the agent sees no attachment and may reply "I don't see any image attached" -- contradicting the warning the user just received. - Add media::format_failed_attachment_note() as a shared helper (single source of truth) for the ContentBlock::Text injected into extra_blocks. - Slack: push the note after the existing warning send (pack_arrival_event partitions Text blocks before the typed prompt; stable iteration order means push trails any STT transcript, which is correct). - Discord: introduce failed_image_files Vec, split the catch-all Err arm into variant-specific arms matching Slack, send a Discord-flavored user warning (without the Slack files:read scope hint), and inject the note. - Add three unit tests for format_failed_attachment_note in media::tests. Addresses the minor UX observation from the openabdev#776 final review comment (antigenius0910, 2026-05-14). Depends on PR openabdev#793 (failed_image_files Vec and MediaFetchError variants introduced there). Fixes openabdev#776 (UX follow-up). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1491969620754567270/1503586535088590868
Fixes #776.
Root cause
When a Slack bot token lacks the
files:readOAuth scope, Slack serves the workspace login HTML page (~55 KB) at HTTP 200 withContent-Type: text/htmlinstead of the requested file binary.download_and_encode_imageaccepted this response because:Content-Typeheader.resize_and_compressfailure for a body <= 1 MB it fell back to forwarding raw bytes under the Slack-reported MIME (image/png), bypassing any format check.The result: a
ContentBlock::Image { media_type: "image/png", data: <base64 of HTML> }flowed to Anthropic, which 400'd withCould not process image. Because claude-agent-acp persists the user message into the session JSONL before the API reply, the bad block replayed on every subsequent turn until an operator manually deleted the JSONL inside the pod.Changes
src/media.rs(primary change)MediaFetchErrorenum:NotAnImage(silent skip),UnsupportedResponseType,InvalidImageBody,SizeExceeded,Network,HttpStatus.validate_image_response(content_type, body)pure helper that:{image/png, image/jpeg, image/gif, image/webp}(strips params, case-insensitive).image::ImageReader::with_guessed_format()(zero new dependencies) and rejects anything that doesn't decode as one of the four supported formats.download_and_encode_imagesignature from-> Option<ContentBlock>to-> Result<ContentBlock, MediaFetchError>, capturing theContent-Typeheader before consuming the response with.bytes().src/slack.rs(call site)On validation failure, collect filenames and post one aggregated user-facing warning after the file loop:
Transient errors (
Network,HttpStatus) log atwarn!and skip silently.src/discord.rs(call site)Same
Resultpattern but log-only on failure (Discord URLs are signed-public; the Slack scope hint is not applicable). Preserves the existingis_video_filefallback forErr(NotAnImage).Tests
13 new unit tests in
src/media.rs::testsforvalidate_image_response, including the exact bug reproduction and a corrupt-GIF regression:Smoke validation:
Result: 24 media tests passed.
Manual test plan (post-deploy)
files:read. Confirm viax-oauth-scopesfromauth.test.files:read, rotate token, redeploy. Upload an image in the same thread.Out of scope / follow-ups
auth.test+apps.permissions.infoat boot to warn on missingfiles:read(useful early-warning, separate concern).download_and_transcribe/download_and_read_text_file: analogous hardening for audio/text-file paths (lower-priority, separate PR).At a Glance
Prior Art & Industry Research
Not applicable: this is a narrow bug fix in existing attachment validation, not a new runtime, persistence, delivery, scheduling, or architectural subsystem.
Why This Approach
The model contract requires real PNG/JPEG/GIF/WebP bytes. Validating after download but before building
ContentBlock::Imageblocks Slack HTML/login pages and corrupted image bodies at the boundary closest to the failure.Generic binary responses such as
application/octet-streamstill pass through to magic-byte validation, so CDN behavior is preserved.Alternatives Considered
application/octet-stream.