Skip to content

fix(llm): reject URL attachments whose extension names a non-image type - #1676

Merged
CoronRing merged 9 commits into
mainfrom
fix/1645-non-image-url-attachments
Oct 22, 2026
Merged

CoronRing merged 9 commits into
mainfrom
fix/1645-non-image-url-attachments

Conversation

@CoronRing

@CoronRing CoronRing commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes #1645. Part of #1347.

A URL attachment whose extension is not in the supported list was sent to the provider as an image_url, so https://example.com/call.mp3 reached the model as an "image". Attachment._init_url now raises a ValueError when the URL's extension is in a fixed set of audio, video, text and Office document extensions (_NON_IMAGE_EXTENSIONS, e.g. .mp3, .mp4, .txt, .docx). The error matches the existing one for local files ("Unsupported attachment format: ...").

With trust_urls=True, the HEAD probe runs first and decides: a URL such as clip.mp4?video-thumbnail=auto that serves image/jpeg is accepted, and one that reports any other type, or whose probe fails, raises.

Unchanged on purpose:

  • Extension-less URLs (CDN and short links) still pass through as images, as the Attachment docstring documents and examples/tutorials/pv_xray_agent.py relies on.
  • Server-script endpoints (.php, .aspx, .pl, ...) are not in the set, so /img.pl?id=3 and a download.pl that serves a PDF behave exactly as on main.

A fixed set is used instead of stdlib mimetypes (suggested in the issue) because the mimetypes table depends on the host: on Python 3.10 to 3.12 without a system MIME file (slim containers), .docx, .xlsx, .m4a, .flac, .ogg and .mkv are unknown, and other libraries can change the table at runtime with mimetypes.add_type.

Type of change

  • Bug fix
  • Feature
  • Breaking change
  • Docs
  • Refactor / chore / build / tests

Checklist

  • Lint & format pass (ruff check . && ruff format .)
  • Tests added/updated and pass locally (pytest tests)
  • Docs updated if user-facing behavior changed
  • Breaking changes include migration notes

Notes

Tests (test_message.py): non-image URLs raise (.mp3, .mp4, .wav, .docx, .xlsx, .m4a, .txt, uppercase, with a query string); extension-less, .php, .ashx, .pl and .svg URLs still pass; with trust_urls=True a non-image extension is accepted when the probe reports an image and raises when it reports audio/mpeg or fails; UserMessage surfaces the error. UserMessage's trust_urls docstring and the modalities docs page describe the new behaviour.

Corner cases left out, and why:

  • Extensions outside the set (.zip, .json, .xml, .rtf, .odt, ...) still pass as images, as on main. Including them risks breaking dynamic image endpoints; classifying them properly needs an explicit mime_type= on Attachment, planned under [Epic] Rich media support #1347.
  • Image extensions outside the supported list (.svg, .bmp, .tiff, .heic, .avif) still pass through without a mime_type. Whether a provider accepts them is a per-provider question, planned as allowlists under [Epic] Rich media support #1347.
  • HEAD-probed audio/video without an extension: with trust_urls=True, an extension-less URL that the probe identifies as audio/* or video/* is still sent as an image, as on main.
  • False positives with trust_urls=False: a URL whose path ends in, say, .mp4 but serves an image now raises. The workaround is trust_urls=True (the probe decides) or passing the bytes directly.

A URL attachment with an extension outside the supported list, such as
.mp3, .mp4 or .docx, was sent to the provider as an image_url. Attachment
now raises a ValueError when stdlib mimetypes maps the extension to an
audio, video, text or Office document type. Extension-less URLs and other
types still pass through as images, as documented, so dynamic image
handlers (.php, .ashx, .aspx) keep working.

Closes #1645
@CoronRing
CoronRing marked this pull request as ready for review October 14, 2026 07:46
Amir-R25
Amir-R25 previously approved these changes Oct 14, 2026

@Amir-R25 Amir-R25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Amir-R25

Copy link
Copy Markdown
Collaborator

PDFs look fine to me. .pdf URLs match _EXTENSION_MIME_MAP before they reach the new check, and URLs with no extension (arxiv) get None from mimetypes, so they still go to the HEAD probe.

One edge case worth knowing about: mimetypes maps some script extensions to text/*. On macOS, .pl is text/plain, .py is text/x-python and .html is text/html. So a URL like https://example.com/cgi-bin/download.pl?id=5 that serves a PDF used to work with trust_urls=True, because the probe saw application/pdf. Now it raises before the probe runs. The same thing happens to a Perl CGI image endpoint like /img.pl?id=3 even with trust_urls=False, which is the kind of break the PR avoids for .aspx/.ashx.

I don't think this blocks the PR, since it's rare and the workaround is easy. If you want to cover it, you could let the probe decide when trust_urls=True and only reject when it doesn't come back as a PDF or image. That would mean dropping the "no request for .mp3" guarantee, though. Or add a line to the PR's "False positives" note saying script extensions can map to text/* too.

@CoronRing

Copy link
Copy Markdown
Collaborator Author

PDFs look fine to me. .pdf URLs match _EXTENSION_MIME_MAP before they reach the new check, and URLs with no extension (arxiv) get None from mimetypes, so they still go to the HEAD probe.

One edge case worth knowing about: mimetypes maps some script extensions to text/*. On macOS, .pl is text/plain, .py is text/x-python and .html is text/html. So a URL like https://example.com/cgi-bin/download.pl?id=5 that serves a PDF used to work with trust_urls=True, because the probe saw application/pdf. Now it raises before the probe runs. The same thing happens to a Perl CGI image endpoint like /img.pl?id=3 even with trust_urls=False, which is the kind of break the PR avoids for .aspx/.ashx.

I don't think this blocks the PR, since it's rare and the workaround is easy. If you want to cover it, you could let the probe decide when trust_urls=True and only reject when it doesn't come back as a PDF or image. That would mean dropping the "no request for .mp3" guarantee, though. Or add a line to the PR's "False positives" note saying script extensions can map to text/* too.

Detailed! Thanks! Rather than letting the probe decide, I've added a short list of server-script extensions (.php, .asp, .aspx, .ashx, .jsp, .cgi, .pl, .py) that skip the check and behave exactly as on main. That covers /img.pl with trust_urls=False and download.pl with trust_urls=True, and .mp3 still raises without a request.

These files are so standard I do not expect them to have a negative impact.

mimetypes can map .pl, .py and other script extensions to text/*, which rejected CGI-style image and PDF endpoints. Those extensions now skip the lookup and keep the pass-through and HEAD-probe behaviour. Also complete the Attachment Raises docstring.
@CoronRing
CoronRing requested a review from Amir-R25 October 14, 2026 19:59
CoronRing added a commit that referenced this pull request Oct 16, 2026
#1677)

## Summary

Closes #1646. Part of #1347.

**Download cap.** With `trust_urls=True`, a PDF URL is downloaded
in-process by `encode()`, which used a single unbounded `read()`. A
large or never-ending response was held fully in memory. `encode()` now
reads the body in 64 KB chunks and raises a `ValueError` as soon as it
passes the limit, so memory use stays near the actual body size whatever
the limit is. A body that ends before its declared `Content-Length` also
raises, so a cut-off PDF is not sent to the provider. The limit is a new
`attachment_max_mb` argument on `Attachment` and `UserMessage` (32 MB by
default), passed through the same way as `attachment_timeout`. It must
be positive and finite; other values raise a `ValueError`, checked in
`encode()` and again when the `Attachment` is built.

**Docs.** The modalities page said image URLs cause no in-process
network I/O. That is true for Railtracks, but litellm 1.102.1 (the
pinned version) downloads some image URLs itself before calling the
provider, and `trust_urls` does not control that:

- Anthropic: `http://` image URLs (`https://` URLs are passed through).
- Gemini (Google AI Studio path, which `GeminiLLM` uses): every image
URL, `http://` and `https://`.

The page now says this in a warning box that names no providers, since
the list depends on the litellm version. It also documents
`attachment_max_mb`, and the `trust_urls` docstring notes it does not
cover litellm's downloads. The `pdf_timeout` docs snippet shows the new
argument.

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] Breaking change
- [x] Docs
- [ ] Refactor / chore / build / tests

## Checklist

- [x] Lint & format pass (`ruff check . && ruff format .`)
- [x] Tests added/updated and pass locally (`pytest tests`)
- [x] Docs updated if user-facing behavior changed
- [ ] Breaking changes include migration notes

## Notes

**Tests:** `test_encoding.py` covers a body over the limit (raises), a
body exactly at the limit (encoded), that every read passes a chunk
size, that local files are not limited, and that 0, -1, inf and NaN are
rejected before any request. Three tests parse raw HTTP through a real
`http.client.HTTPResponse` over a `BufferedReader`, as a socket would: a
body with no `Content-Length` under a 1,000,000 MB limit (on the
previous read this raised `MemoryError`), a body cut short of its
`Content-Length` (raises), and a complete one (encoded).
`test_message.py` extends the existing threading test so
`attachment_max_mb` reaches `encode()`, and rejects the same invalid
values on `Attachment`. Unit and integration suites pass locally, except
the two MCP server tests whose fixture needs `pip`, which my uv
environment does not have. `mkdocs build --strict` passes, and mypy on
`docs/scripts/multimodal.py` passes (and flags the new argument against
`main`, so the check is real).

**Behaviour change:** a `trust_urls=True` PDF URL larger than 32 MB used
to be downloaded and sent; it now raises unless `attachment_max_mb` is
raised. 32 MB is already at or above most providers' request limits once
the file is base64-encoded.

**Corner cases left out, and why** (the full fix is the single fetch
policy planned under #1347):

- **litellm's own downloads** are documented, not prevented or capped.
Stopping them means routing those URLs through a guarded Railtracks
fetcher.
- **Redirects** are still followed by `urllib`, including to internal
hosts. That is part of the SSRF surface, along with **no blocking of
private or link-local addresses**.
- **The timeout is per socket operation, not a total deadline.** A
server that trickles bytes can keep the download alive for a long time,
though it can no longer exceed the size cap. The chunked read loop is
where a total deadline would go.
- **The cap is on raw bytes;** base64 adds about a third, so a file just
under the cap can still exceed a provider's request limit. Per-provider
limits are planned under #1347.
- **Local files are not capped:** they are the developer's own files,
not a remote input.
- The `Content-Length` header is not checked up front. The bounded read
already limits the download to the cap, so a header check would only
save that much traffic.
- **Error messages include the full URL**, as the existing "Failed to
encode URL" message already did, so a signed URL's query string can
reach logs. Redacting it belongs with the guarded fetcher.

**Merge note:** this PR and #1676 (from #1645) both add a docstring line
right after `attachment_timeout` in `Attachment.__init__`, so whichever
merges second needs a trivial rebase of that one hunk. Nothing else
overlaps.

@Amir-R25 Amir-R25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension-less and server-script handling looks right to me. The main issue is in the first comment: some of the types this means to reject (.docx, .xlsx, .m4a, ...) are only rejected on machines that have a system MIME file.

Comment thread packages/railtracks/src/railtracks/llm/message.py Outdated
Comment thread packages/railtracks/src/railtracks/llm/message.py Outdated
Comment thread packages/railtracks/tests/unit_tests/llm/test_message.py
Comment thread packages/railtracks/src/railtracks/llm/message.py Outdated
Comment thread packages/railtracks/src/railtracks/llm/message.py Outdated
Amir-R25
Amir-R25 previously approved these changes Oct 22, 2026

@Amir-R25 Amir-R25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Guan, posted some review comments, none are blocking, leave it to you decide which to take on :)

…e decide

Replace the host-dependent mimetypes lookup with a fixed set of audio,
video, text and Office extensions, so .docx, .xlsx and .m4a are rejected
on every Python version and host. With trust_urls=True the HEAD probe now
runs first, so thumbnail endpoints such as clip.mp4?video-thumbnail=auto
that serve an image are accepted again. Document the behaviour and the
ValueError on UserMessage.

@Amir-R25 Amir-R25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@CoronRing
CoronRing merged commit d28d47c into main Oct 22, 2026
11 checks passed
@CoronRing
CoronRing deleted the fix/1645-non-image-url-attachments branch October 22, 2026 20:58
@CoronRing

Copy link
Copy Markdown
Collaborator Author

Wonder! Thank for the review @Amir-R25

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

URL attachments with a non-image extension (.mp3, .mp4, .docx) are silently sent as images

2 participants