[Bugfix][Multimodal] Validate base64 video payloads, matching image and audio - #54323
Conversation
…nd audio VideoMediaIO.load_base64 was the only base64 loader in the tree decoding without validate=True. pybase64 then silently discards every character outside the base64 alphabet and re-packs what is left, so the decoder is handed a byte stream no client ever sent and the resulting error blames the video rather than the encoding. Pass validate=True, matching ImageMediaIO, AudioMediaIO and the VideoEmbeddingMediaIO loader in the same module. binascii.Error is a ValueError subclass, so it maps to 400 through the existing handler with no new exception type. Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in>
6b54207 to
c10adc5
Compare
|
Rebased onto current The retracted claim had survived in the diff. The body was rewritten, but the commit The case changed shape while this sat. # vllm/multimodal/media/video.py:193 VideoMediaIO
return self.load_bytes(pybase64.b64decode(data))
# vllm/multimodal/media/video.py:251 VideoEmbeddingMediaIO
return self.load_bytes(pybase64.b64decode(data, validate=True))Fresh verification against Backends re-checked, including The standing offer holds: if consistency alone does not clear the bar now that the |
Co-authored-by: Isotr0py <2037008807@qq.com> Signed-off-by: Isotr0py <2037008807@qq.com>
|
/ci run |
|
✅ Triggered Buildkite CI #88713 for commit |
…nd audio (vllm-project#54323) Signed-off-by: hotragn <hotragn.pettugani_2024@woxsen.edu.in> Signed-off-by: Isotr0py <2037008807@qq.com> Co-authored-by: Isotr0py <2037008807@qq.com>
Purpose
VideoMediaIO.load_base64is the only media loader in the tree that decodesbase64 without validation:
Without
validate=True,pybase64silently discards every character outsidethe base64 alphabet and re-packs what is left. The resulting byte stream is not
the payload the client sent — every byte after the first invalid character is
shifted — and it is handed directly to the video decoder.
ImageMediaIO.load_base64media/image.py:105ImageEmbeddingMediaIO.load_base64media/image.py:154AudioMediaIO.load_base64media/audio.py:561AudioEmbeddingMediaIO.load_base64media/audio.py:604VideoEmbeddingMediaIO.load_base64media/video.py:251VideoMediaIO.load_base64media/video.py:193Line numbers are against
mainat29332cf936. The last row is the point: since#54242 landed
VideoEmbeddingMediaIOon 2026-08-31 — after this PR was opened — the inconsistency is no longer video versus
the other modalities. It is between two loaders in the same module, three classes
apart.
Two concrete consequences:
sequence that no client ever sent, derived from a malformed one. Rejecting
undecodable input before it reaches a C/C++ media parser is the same
rationale that made [Bugfix][Multimodal] Reject malformed base64 audio with 400 instead of 500 #53744 worth merging for audio.
surfaces as
Could not open video stream— a message about the video,when the actual fault is the encoding. With
validate=Truethe clientgets
binascii.Errormapped to 400, naming the real problem.What this PR does not claim
ImageMediaIO/AudioMediaIOhad a genuine status-code bug (LibsndfileErrorinherits
RuntimeError→ 500). Video does not, under the default backend:opencv's failure is already a
ValueError→ 400. I have not verified thebehaviour of the
torchcodecorpynvvideocodecbackends on shifted input,so I am not claiming a 500 there either.
Reachability
MediaConnector._load_data_urlparsesdata:<media-type>;base64,<data>anddispatches straight to the loader (
vllm/multimodal/media/connector.py:331), soany chat request carrying
"video_url": {"url": "data:video/mp4;base64,<...>"}reachesmedia/video.py:169. Thevideo/jpegbranch above it is unaffected — itdelegates to
image_io.load_base64, which is already strict.Approach
Pass
validate=True, matching image and audio.pybase64then raisesbinascii.Error, aValueErrorsubclass, which maps to 400 through theexisting handler. No new exception type and no new handler.
Behaviour change worth calling out
validate=Truealso rejects base64 containing line breaks or whitespace. RFC2045 permits line-wrapped base64, so a payload produced by
base64 clip.mp4orJava's MIME encoder decodes today and will be rejected with a 400 after this
change.
I chose to match image and audio rather than invent a third behaviour: image has
been strict since January and audio since last week, so a client that wraps its
base64 already fails on those modalities. If maintainers would rather tolerate
RFC 2045 whitespace, the right fix is to strip it in all three loaders, which I
am happy to send as a follow-up.
Relationship to #46836
#46836 also touches this line,
adding a size check immediately before it, but leaves the decode itself lenient —
it addresses payload size, not payload validity. The two changes are
complementary; whichever lands second is a one-line rebase.
Test Plan
Adds
test_load_base64_rejects_malformed, which drivesload_base64("video/mp4", ...)with a payload containing!!!@@@. The testregisters a sentinel video backend that raises
AssertionErrorif it is evercalled, so the assertion is specifically that the malformed input is rejected
before the decoder runs — not merely that some exception escapes. This is the
property the change is actually about, and it is backend-independent, so the
test does not depend on which decoder is configured.
Test Result
Re-run 2026-09-12 on a fresh x86_64 Linux runner against current
main(
d9105ea80), Python 3.12,VLLM_USE_PRECOMPILED=1 uv pip install -e ..Before — the branch's test file checked out on top of
main, so only thesource fix is missing. The sentinel backend is what makes this specific: the
failure is not "some exception was raised", it is that the decoder was entered
at all, because the lenient decode stripped
!!!@@@and reproduced 128plausible-looking bytes.
After — the whole file on this branch:
Whole directory,
pytest tests/multimodal/media, both revisions:Zero failures on either side. The six-test gap is not this PR: one test is the
new one, and the other five arrive with #53675 and #55642, which landed between
d9105ea80and the29332cf936this branch is rebased onto.pre-commithooks applicable to the two changed files —ruff-check,ruff-format,typos,mypy(3.12),check-spdx-header,check-root-lazy-imports,check-forbidden-imports,check-torch-cuda-call—all pass.
If maintainers judge that consistency alone does not clear the bar now that the
status-code claim is gone, I am happy to close this.
AI assistance was used to research and draft this change. I have reviewed every
changed line and run the tests above.