Handling Files with Missing or Wrong Extensions

Identify the file by its content — magic bytes for binary formats, a strict parse for text formats — then treat the extension as one of three cases: it matches (accept), it is missing or generic (accept and assign the canonical extension for the detected type), or it contradicts the content (accept under the detected type if that type is allowed, reject otherwise); store the original filename only as display metadata and generate storage keys and download names from the detected type.

Real files arrive misnamed all the time, for innocent reasons: a phone saves IMG_2041 with no extension, a messaging app renames a HEIC to .jpg without converting it, a user renames report.pages to report.pdf hoping it will open, a Windows download strips the extension, a scanner writes .JPG in capitals or .jpeg instead of .jpg. Treat every mismatch as an attack and you reject legitimate uploads; trust the extension and you end up serving a HEIC as image/jpeg or storing HTML under .png. This page belongs to file type detection in the browser in upload fundamentals and browser APIs. The detection itself is covered by detecting file type from magic bytes in JavaScript.

When to use this approach

  • Users upload files from many sources — phones, scanners, chat apps, email attachments — where names are unreliable.
  • You serve files back with a Content-Type and a download filename, and both must be correct.
  • You need a consistent rule for the “wrong extension” case rather than ad hoc exceptions.

Prerequisites

  1. A content sniffer for every binary format you accept (JPEG, PNG, GIF, WebP, HEIC, PDF, MP4, ZIP-based office formats).
  2. A way to validate text formats that have no magic bytes — CSV, JSON, SVG, plain text — by parsing a prefix.
  3. A single table mapping detected MIME types to canonical extensions, shared by client and server.

The four cases

Decision table for extension versus detected content If the extension matches the detected type, accept. If the extension is missing or generic like bin or tmp, accept and assign the canonical extension. If the extension contradicts the content and the detected type is allowed, accept under the detected type and rename. If the detected type is not allowed or cannot be determined, reject whatever the extension says. Content decides; the extension only changes the name name → content example outcome matches photo.jpg → JPEG accept missing or generic IMG_2041 → PNG accept, name .png contradicts, allowed IMG_7.jpg → HEIC accept as HEIC contradicts, not allowed report.pdf → ZIP reject unknown content data.png → ??? reject No row accepts content you do not allow, and no row rejects allowed content for its name alone.
The table is symmetric in the way that matters: names never make bad content acceptable, nor good content unacceptable.

Implementation

export interface TypeInfo { mime: string; ext: string; kind: "image" | "video" | "document" | "data" }

// One table, shared by client and server.
export const CANONICAL: Record<string, TypeInfo> = {
  "image/jpeg": { mime: "image/jpeg", ext: "jpg", kind: "image" },
  "image/png": { mime: "image/png", ext: "png", kind: "image" },
  "image/webp": { mime: "image/webp", ext: "webp", kind: "image" },
  "image/heic": { mime: "image/heic", ext: "heic", kind: "image" },
  "application/pdf": { mime: "application/pdf", ext: "pdf", kind: "document" },
  "video/mp4": { mime: "video/mp4", ext: "mp4", kind: "video" },
  "text/csv": { mime: "text/csv", ext: "csv", kind: "data" },
  "application/json": { mime: "application/json", ext: "json", kind: "data" },
};

// Extensions that mean "no real information".
const GENERIC = new Set(["", "bin", "tmp", "dat", "file", "download", "octet-stream"]);
// Accepted spellings for each canonical extension.
const ALIASES: Record<string, string> = { jpeg: "jpg", jpe: "jpg", jfif: "jpg", heif: "heic", tif: "tiff" };

export function extensionOf(name: string): string {
  const base = name.split(/[\\/]/).pop() ?? "";
  const dot = base.lastIndexOf(".");
  if (dot <= 0) return "";                                      // ".bashrc" has no extension
  const ext = base.slice(dot + 1).toLowerCase();
  return ALIASES[ext] ?? ext;
}

async function sniffBinary(file: Blob): Promise<string | null> {
  const h = new Uint8Array(await file.slice(0, 16).arrayBuffer());
  const at = (o: number, bytes: number[]) => bytes.every((b, i) => h[o + i] === b);
  if (at(0, [0xff, 0xd8, 0xff])) return "image/jpeg";
  if (at(0, [0x89, 0x50, 0x4e, 0x47])) return "image/png";
  if (at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50])) return "image/webp";
  if (at(0, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf";
  if (at(4, [0x66, 0x74, 0x79, 0x70])) {
    const brand = String.fromCharCode(...h.slice(8, 12));
    return ["heic", "heix", "mif1", "msf1"].includes(brand) ? "image/heic" : "video/mp4";
  }
  if (at(0, [0x50, 0x4b, 0x03, 0x04])) return "application/zip";
  return null;
}

async function sniffText(file: Blob): Promise<string | null> {
  const head = await file.slice(0, 64 * 1024).text();
  if (//.test(head)) return null;                         // NUL bytes: not text
  const t = head.trimStart();
  if (t.startsWith("{") || t.startsWith("[")) {
    try { JSON.parse(file.size <= 64 * 1024 ? head : t.slice(0, t.lastIndexOf("}") + 1) || "{}"); return "application/json"; }
    catch { /* not JSON, fall through */ }
  }
  const lines = head.split(/\r?\n/).slice(0, 20).filter(Boolean);
  const cols = lines.map((l) => l.split(",").length);
  if (lines.length >= 2 && cols.every((c) => c === cols[0] && c > 1)) return "text/csv";
  return null;
}

export type Outcome =
  | { action: "accept"; type: TypeInfo; storedName: string; renamed: boolean }
  | { action: "reject"; reason: string };

export async function resolveType(file: File, allowed: Set<string>): Promise<Outcome> {
  const detected = (await sniffBinary(file)) ?? (await sniffText(file));
  if (!detected) return { action: "reject", reason: "We couldn't recognise this file's format." };
  if (!allowed.has(detected)) {
    return { action: "reject", reason: `This file is ${CANONICAL[detected]?.ext.toUpperCase() ?? detected}, which isn't accepted here.` };
  }
  const info = CANONICAL[detected];
  const ext = extensionOf(file.name);
  const stem = file.name.replace(/\.[^./\\]+$/, "").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "file";
  const matches = ext === info.ext;
  return {
    action: "accept",
    type: info,
    storedName: `${stem}.${info.ext}`,
    renamed: !matches && !GENERIC.has(ext) ? true : !matches,
  };
}

// Usage
const allowed = new Set(["image/jpeg", "image/png", "image/heic", "application/pdf"]);
for (const f of Array.from(document.querySelector<HTMLInputElement>("#files")!.files ?? [])) {
  const r = await resolveType(f, allowed);
  console.log(f.name, "→", r.action === "accept" ? `${r.storedName}${r.renamed ? " (renamed)" : ""}` : r.reason);
}

Line-by-line on the decisions that matter

  • Binary sniff first, then text. Binary signatures are unambiguous; text detection is heuristic. Trying binary first stops a PNG that happens to start with printable bytes from being called text.
  • Rejecting on NUL bytes in text detection. Text formats essentially never contain 0x00; binary formats almost always do in the first 64 KB. It is the cheapest reliable way to avoid calling binary data CSV.
  • ALIASES. .jpeg, .JPG and .jfif are the same format. Normalising before comparing stops the “wrong extension” path firing for spelling differences.
  • Storing under the canonical extension. Storage keys and download names derived from the detected type mean a HEIC renamed .jpg is served as image/heic with a .heic name — so the browser or OS opens it correctly — and a PNG saved without an extension downloads as .png.
  • Sanitising the stem. The original name is user input. Keep it for display, but strip path separators and unusual characters before it goes anywhere near a key or a Content-Disposition header.
  • renamed flag. Tell the user when you change a name, briefly: “Saved as IMG_7.heic — the file was HEIC, not JPEG.” It explains why their download looks different.

Why “wrong extension” is not automatically malicious

Where mismatched names come from In a sample of 10 thousand uploads, 312 had a name that did not match their content. Most came from phones and chat apps renaming HEIC as JPG, next from missing extensions, then from case or spelling variants, and only a handful were clear attempts to disguise a disallowed type. 312 mismatches in 10,000 uploads, by cause (illustrative) HEIC named .jpg 141 no extension .jpeg / .JPG / .jfif 90 70 disguised disallowed type 11 A reject-all rule would have refused 301 legitimate uploads to stop 11 that content checks stop anyway.
Content-based decisions stop the few disguised files and let the many misnamed ones through.

The 11 disguised files in that sample were stopped by the “contradicts, not allowed” row: their content was HTML or executables, which were not on the allowed list whatever they were called. The extension did nothing to stop them; the content check did everything.

Serving files back correctly

Getting the type right on the way in only matters if it is used on the way out. Serve every stored file with the detected Content-Type, never one guessed from the stored key or copied from the upload request, and add X-Content-Type-Options: nosniff so browsers do not second-guess it. For downloads, build Content-Disposition from the canonical name, with an RFC 5987 filename* parameter for non-ASCII names. For anything that is not an image, audio or video you render yourself, prefer attachment over inline, so a user-uploaded PDF or SVG opens in a viewer rather than as a page in your origin.

These three headers close the loop the extension problem opened: the browser receives an accurate type, is told not to guess, and saves files with a name that matches their content.

Where the detected type and the original name each end up The uploaded bytes are sniffed to a detected type, which is looked up in the canonical table. The detected type sets the storage key extension, the Content-Type header and the download filename extension. The original filename is kept only as display metadata, sanitised, and used as the stem of the download name. Bytes decide the type; the name is decoration uploaded bytes first 16 B / 64 KB original name IMG_7.jpg detected: image/heic canonical ext .heic sanitised stem IMG_7 (display only) key …/a91f.heic Content-Type: image/heic download "IMG_7.heic" Every header the browser acts on comes from the detected type; the user's name only survives as a label.
Separating what the file is from what it was called removes a whole class of serving bugs.

Configuration gotchas

Office documents detected as ZIP. DOCX, XLSX and PPTX are ZIP archives. To accept them, open the archive’s central directory (or the first entry) and look for word/, xl/ or ppt/ paths; accepting any ZIP accepts anything inside a ZIP.

CSV detection fails on single-column files. A list of email addresses has no commas. Accept single-column text only where the product expects it, and otherwise ask the user to confirm the format.

Server and client disagree. The client allowed a file the server’s file-type or libmagic rejected, or vice versa, because each used its own list. Share the canonical table, and make the server’s decision final — see validating file signatures with libmagic in Node.js.

UTF-8 BOM breaks JSON detection. Files exported from Windows tools may start with EF BB BF. Blob.text() strips a UTF-8 BOM, but byte-level checks do not; strip it before testing for { or [.

Verification

import { strict as assert } from "node:assert";

const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 1, 0, 0, 1]);
const allowed = new Set(["image/jpeg", "image/png", "application/pdf"]);

const noExt = await resolveType(new File([jpegBytes], "IMG_2041"), allowed);
assert.ok(noExt.action === "accept" && noExt.storedName === "IMG_2041.jpg");

const spelled = await resolveType(new File([jpegBytes], "scan.JPEG"), allowed);
assert.ok(spelled.action === "accept" && spelled.storedName === "scan.jpg");

const zipAsPdf = await resolveType(new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0])], "report.pdf"), allowed);
assert.equal(zipAsPdf.action, "reject");
console.log("extension handling ok");

Frequently Asked Questions

Should I warn users when I rename their file?

Briefly, and only when the change is meaningful (a different format, not just .jpeg to .jpg). A one-line note avoids confusion when their download has a different extension than the file they uploaded.

What about files with double extensions like invoice.pdf.exe?

Content detection handles them: the bytes are an executable, which is not on the allowed list, so the file is rejected whatever its name. When displaying names, show the full original name so users can see what they uploaded.

Can I rely on file.type for the “missing extension” case?

No — the browser derives file.type from the extension, so with no extension it is usually empty. That is exactly why content sniffing is needed; see why browser MIME types are unreliable.