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 HTML

What 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 %PDF signature.
  • 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

OptionValuesWhat 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 stored Content-Type with 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 .png whose 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 in metadata, so a sniffed bucket is indistinguishable from an un-sniffed one — safe to enable or remove at any time.
  • Reads, url(), copy, and move pass straight through. The plugin only acts on upload; 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 the Files instance 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.

On this page