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 8 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.
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.