Trust the Bytes, Not the Filename: Content Detection and Safe Parser Routing in the Browser
When a user uploads a data export to Follower Change, the file arrives with three claims about what it is: its name, its extension, and the MIME type the browser guessed. Our parser believes none of them. Everything it r
When a user uploads a data export to Follower Change, the file arrives with three claims about what it is: its name, its extension, and the MIME type the browser guessed. Our parser believes none of them. Everything it routes — a ZIP archive, JSON, an HTML export, a JavaScript data file, a CSV — is identified by its content, in a fixed order, before any parser is allowed to touch it. This post walks through that detection pipeline and the safety rules around it, because "what is this file?" is a security question, not a convenience.
Step 0: Read the head once, normalize it
Detection works on two views of the same bytes: the raw prefix for binary signatures, and a decoded text head for everything else.
// Mirrors production (lib/parser/detect.ts), lightly condensed.
const TEXT_HEAD_BYTES = 2048;
function textHead(bytes: Uint8Array): string {
const head = bytes.subarray(0, Math.min(bytes.length, TEXT_HEAD_BYTES));
return new TextDecoder("utf-8", { fatal: false }).decode(head);
}
The head is 2,048 bytes, decoded as UTF-8 with replacement (never throwing on invalid sequences), then trimmed and lowercased before matching. Lowercasing once, up front, means every rule after it can compare against lowercase patterns — export files do not respect your casing expectations, and Window.YTD is not a different format from window.ytd.
Step 1: Containers by magic bytes — the full signature
A ZIP archive announces itself in its first four bytes. We compare all four, against all three signatures a ZIP can open with:
// Production signatures, verbatim.
const ZIP_MAGIC: Array<[number, number, number, number]> = [
[0x50, 0x4b, 0x03, 0x04], // standard zip — "PK\x03\x04"
[0x50, 0x4b, 0x05, 0x06], // empty archive
[0x50, 0x4b, 0x07, 0x08], // spanned archive
];
Checking a partial prefix is how a file that merely starts with the letters "PK" gets routed to an archive parser. Four bytes is the signature; anything less is a guess. If the magic matches, the file is an archive no matter what its name claims — and if it doesn't, no .zip extension can promote it later (the extension fallback below only applies when nothing in the content matched at all).
Step 2: Text formats by leading structure, in a fixed order
JSON, HTML, JavaScript data files, and CSV have no magic bytes, so the lowercased text head is matched structurally — in this exact order:
// Order mirrors production: JSON → HTML → X-JS → CSV → extension.
const head = textHead(bytes).trimStart().toLowerCase();
if (head.startsWith("{") || head.startsWith("[")) return "json";
if (head.startsWith("<!doctype html") || head.startsWith("<html")) return "html";
if (head.startsWith("window.ytd.")) return "xjs"; // X archive data files
// CSV: structural, explained below.
if (looksLikeSubscriptionsCsv(bytes)) return "csv";
// Nothing matched: the extension gets the last word, and only then.
switch (extensionOf(filename)) {
case "json": return "json";
case "html": case "htm": return "html";
case "zip": return "zip";
case "js": return "xjs";
case "csv": return "csv";
default: return "unknown";
}
(The CSV check and the helper names are simplified for readability; the order and the matching rules are the production ones. MIME types are never consulted anywhere in the pipeline.)
Order is not pedantry. A JSON payload can contain the string <html in a bio field; an X data file is JavaScript that is not JSON until its prefix is removed. The first matching rule wins, so the rules are sequenced from the most structurally certain to the least.
Step 3: CSV — the format with no signature
CSV is the honest problem child: no magic bytes, no doctype, no reliable opening token — and in our pipeline it carries two real exports: YouTube Takeout's subscriptions.csv and LinkedIn's Connections.csv (the latter identified by its path when it arrives inside LinkedIn's archive; the shared CSV reader handles both).
YouTube's header names vary by locale, so detection can't match a literal header string. What production matches is the shape of the first line: it must contain commas, and its first field must match /channel/i after trimming an optional opening quote — the one structural constant across localizations of "Channel Id, Channel Url, …".
Once routed, the CSV reader itself (a minimal RFC-4180-style parser: quoted fields, "" escapes, embedded commas and newlines) treats the file as hostile until proven otherwise: caps on total bytes (20 MB), rows (200,000), and field length (4,096 characters), with a hard failure — not a truncation — when a quoted field never terminates. A CSV from an export service is still an uploaded file, and uploaded files get limits, not trust.
Step 4: Routing is a security boundary
Detection's output is a route, not a verdict. Each route leads to exactly one parser, and each parser only ever sees bytes that claimed its shape:
- The JSON route validates defensively — fields are typed as
unknownand narrowed with guards; undocumented fields are ignored, documented ones type-checked. - The X-JS route never executes anything: the
window.YTD.… =assignment prefix is stripped as text and the remainder is handed toJSON.parse. (This was the centerpiece of our earlier article, so here it gets one sentence — the point for detection is that a distinct route exists precisely so this file never reaches a JSON parser raw or an evaluator at all.) - The HTML route parses with a static parser and harvests profile links from anchor
hrefs. The markup is never inserted into the DOM. An export file is untrusted input with a download button's good reputation.
Step 5: An archive is a bundle of untrusted files
A detected ZIP changes the question from "what is this file?" to "what is each file inside it?" Our archive layer (fflate, fully client-side) therefore re-runs detection per entry — the container's identity says nothing about its contents — and enforces, around that:
-
Traversal is impossible by construction: entries are never written to disk, and entries whose names contain
..segments, absolute paths, or drive letters are skipped outright. - Sizes are checked twice: each entry's declared size and the archive's declared total are capped before extraction, and the actual decompressed sizes are verified again after — a central directory can lie, so the defense doesn't rely on it.
- The entry count is capped (200 in our configuration), turning pathological archives into a clear user-facing error instead of a frozen tab.
Step 6: Validate after routing, fail without leaking
Only after routing does validation begin, and its failures are designed to say nothing useful to an attacker: parse errors carry stable codes and user-safe messages, and never echo file contents back into the UI. "We know what this claims to be" and "this is well-formed" stay separate claims, checked by separate layers, in that order.
The pipeline, on one screen
- Magic bytes first — full signatures, containers only.
- A 2,048-byte, lowercased text head for everything else.
- Structural matches in certainty order: JSON → HTML → X-JS → CSV.
- Extension as fallback only; MIME never.
- CSV detected by shape, parsed with hard caps.
- One route per format; parsers validate defensively; HTML stays inert; JS data is text, never code.
- Archives re-detect every entry, skip hostile names, and verify sizes twice.
- Errors are stable, generic, and content-free.
Seven platforms ship our analyzer seven mutually inconsistent export formats. The detection pipeline is why the rest of the system never has to care which one a user is holding — and why a misnamed, mislabeled, or hostile file meets the same fixed sequence of questions before it meets any parser at all.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.