Read trusts file extension: a non-image file named *.png permanently bricks the session (400)

Status Open
Maintainer reply None cached
Activity 0 comments · opened Oct 15, 2026

Environment

  • Claude Code v2.1.89
  • Windows 11 Pro for Workstations (10.0.22631)
  • Model: claude-sonnet-4-6

Summary

When Read is pointed at a file whose extension says image but whose bytes are not an image (e.g. a PDF named foo.png), the tool surfaces it as an image and attaches it to message.content with a media_type derived from the extension. The API correctly rejects it, and from that point on every request in the session fails, because the bad attachment is replayed from history on every turn.

Steps to reproduce

  1. Create a non-image file that has an image extension. PyMuPDF does this by accident very easily, because Document.save() writes a PDF regardless of the extension:

``bash
python -c "import fitz; d=fitz.open(); d.new_page(); d.save('x.png')"
`
x.png now begins with the bytes %PDF-1.7`.

  1. In Claude Code: Read x.png
  • The tool output is Read image (N KB) — no warning, no validation error.
  1. Send any subsequent message (including /continue).

Actual

API Error: 400 {"error":{"message":"messages.N.content.0.tool_result.image[0]:
You have uploaded an unsupported image. Please make sure your image is valid and
has one of the following formats: webp, png, jpeg, and gif.", ...}}

Every retry fails identically, because the offending attachment lives in history and is re-sent each turn.

Expected

Read should validate the file's magic bytes before treating it as an image. If the bytes don't match the claimed type, it should refuse to attach and report something like x.png is not a valid image (found PDF header) — at minimum, warn.

Impact

The session becomes unusable and there is no in-product recovery path:

  • /continue retries the same in-memory request.
  • The error names messages.N (an API array index), not the offending file, so the user can't tell which attachment broke it.
  • The only known fix is editing the session JSONL on disk to replace the bad image block, then --resume. That is not something a normal user should have to do.

Suggested fixes

  1. Sniff bytes, not extensions. Before emitting an image block, verify the magic number (\x89PNG, \xFF\xD8\xFF, RIFF…WEBP, GIF8); on mismatch, return a normal text error instead of attaching.
  2. Make the failure recoverable and legible. Name the offending file in the error surfaced to the user, and offer a way to drop the last attachment from history so the session isn't bricked.

Notes

The same bytes are also stored in the transcript's local-only toolUseResult field as {"type":"image","file":{"base64":...,"type":"image/png","originalSize":...}} — i.e. it is labeled image/png purely from the extension, which points at (1) as the root cause.

View original on GitHub ↗