contentType
A security guard that decides each upload's Content-Type from its bytes, not the client's claim. Magic-byte sniffing stops a mislabeled .png that's really HTML or SVG from being stored as an image and served inline. No metadata, no native dependencies, and it never buffers the whole body.
The built-in contentType() plugin sets an upload's Content-Type from what the bytes actually are, not what the client said they were. On upload it magic-byte-sniffs the body and either corrects the stored type to match (the default) or rejects a mismatch — so a .png whose bytes are really HTML or SVG can't be stored under an image type and later served inline. It's the wrap plugin counterpart to validation(): validation vets the declared type, contentType() verifies the real one.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { contentType } from "files-sdk/content-type";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [contentType({ onMismatch: "reject" })],
});
await files.upload("avatar.png", pngBytes); // ok — bytes are a PNG
await files.upload("avatar.png", htmlBytes); // throws — bytes are HTMLWhat it recognizes
The sniffer is deliberately scoped to where a verdict is unambiguous and useful:
- Images — PNG, JPEG, GIF, BMP, WebP, TIFF, and ICO, by their magic bytes.
- PDF — the
%PDFsignature. - HTML, SVG, and XML — the security-relevant part. These are text, so they have no fixed magic bytes; a leading text scan (skipping a BOM and whitespace) catches
<!doctype html>,<html>,<script>,<svg>,<?xml>, and friends.
Container formats whose magic bytes are shared by many real types — ZIP (which also backs .docx, .jar, .epub), gzip, and the ftyp audio/video family — are intentionally not sniffed: relabeling them would do more harm than good. Bodies it can't identify are handled by onUnknown.
You can run the same detection yourself with the exported detectContentType(bytes), which returns the sniffed MIME type or undefined.
Options
| Option | Values | What it does |
|---|---|---|
onMismatch | "correct" (default), "reject" | What to do when the sniffed type contradicts the declared one. |
onUnknown | "trust" (default), "reject" | What to do when the bytes match no known signature. |
onMismatch
When the bytes disagree with the type the caller declared (or that the key's extension implies):
"correct"(default) — overwrite the storedContent-Typewith the sniffed one, so the object is always stored as what it actually is. The mislabeled file still lands, but under its true type."reject"— throw, so the upload never reaches the adapter. The security-hardening choice: a.pngwhose bytes are HTML is refused outright.
A declared application/octet-stream (i.e. no real claim) is treated as unset rather than a contradiction — the sniffed type fills it in under both modes. And when the declared type already agrees with the bytes, it's left exactly as you set it, so a text/html; charset=utf-8 keeps its charset parameter.
onMismatch: "correct" makes the stored type honest; it does not make a
dangerous file safe. Detecting that an upload is really HTML and storing it as
text/html still leaves it executable if you serve it inline. To block
mislabeled active content, use onMismatch: "reject"; to serve user uploads
safely, pair this with a forced attachment disposition on
url().
onUnknown
When the bytes match no known signature:
"trust"(default) — keep the declared/inferred type. Sniffing only overrides types it's sure about, so a legitimate.csv,.docx, or arbitrary binary keeps its declared type untouched."reject"— throw. A strict allowlist-by-signature posture: nothing lands unless its bytes are recognized.
Ordering
Put contentType() first, before any body-transforming plugin, so it sniffs the caller's original bytes rather than compressed or encrypted ones:
plugins: [contentType(), compression(), encryption(key)];Things to keep in mind
- It never buffers the whole body. Only the first 512 bytes matter, so known-length bodies (strings,
Uint8Array,Blob,ArrayBuffer) are peeked in place with no copy, and streams stay streaming — the prefix is read and replayed, the rest flows straight through. This is the one body-touching plugin that doesn't disable streaming uploads. - It writes no metadata. The verdict lands in the object's own
Content-Type; nothing rides along inmetadata, so a sniffed bucket is indistinguishable from an un-sniffed one — safe to enable or remove at any time. - Reads,
url(),copy, andmovepass straight through. The plugin only acts onupload; it stores the right type up front, so there's nothing to undo on the way back out. signedUploadUrl()fails closed. A presigned upload writes directly to the store, bypassing the sniff, so minting one would be a silent hole — it throws. Upload through theFilesinstance to enforce sniffing.- It's a sniffer, not a validator. It decides the stored type; it doesn't enforce a size limit or an allowed-type list. Pair it with
validation()when you want both.
compression
Transparently compress object bodies at rest with gzip, deflate, or deflate-raw. The original size and algorithm ride along in metadata, and reads decompress automatically - provider-agnostic, no native dependencies.
dedup
Content-address object bodies by their hash so identical content is stored only once. Re-uploading the same bytes skips the upload, and copies share a single stored blob. No native dependencies; works on any adapter that supports metadata.