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-Typeand a download filename, and both must be correct. - You need a consistent rule for the “wrong extension” case rather than ad hoc exceptions.
Prerequisites
- A content sniffer for every binary format you accept (JPEG, PNG, GIF, WebP, HEIC, PDF, MP4, ZIP-based office formats).
- A way to validate text formats that have no magic bytes — CSV, JSON, SVG, plain text — by parsing a prefix.
- A single table mapping detected MIME types to canonical extensions, shared by client and server.
The four cases
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,.JPGand.jfifare 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
.jpgis served asimage/heicwith a.heicname — 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-Dispositionheader. renamedflag. 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
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.
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.