2026-07-20
Hardening NearScrub's parser: a PNG integer overflow and a zip-bomb guard
NearScrub strips EXIF/GPS data, XMP, IPTC, and other embedded metadata from JPEG, PNG, PDF, and
Office files, entirely in the browser — no upload, no server, same architecture as every other near
app. That last fact raises an obvious question: if there's no server and no other users, who
exactly are you defending against by hardening a file parser? The honest answer is: the person
using the tool, against their own file. A photo pulled off an old phone, a PNG re-saved by three
different pieces of software over a decade, a docx that's been through more than one buggy
converter — none of that is malicious, but a parser that assumes well-formed input will happily
walk off the end of a buffer or try to allocate gigabytes when it meets a corrupted or
adversarially crafted one. There's no backend to crash, but there's still a browser tab open to a
real person's file, and making that tab hang or crash mid-scrub is a real failure mode even in a
fully client-side, single-user tool. This post covers two concrete cases in
scrub-core.js where that mattered enough to fix.
A negative chunk length in a PNG
PNG files are a sequence of length-prefixed chunks, and the length field is 4 bytes,
big-endian, meant to be read as an unsigned 32-bit integer. pngChunks() originally
read it the straightforward way: (b0<<24)|(b1<<16)|(b2<<8)|b3. That
looks correct and is correct for the overwhelming majority of real PNGs, whose chunks are at most a
few megabytes. It's wrong for one specific case: JavaScript's bitwise operators work on 32-bit
signed integers, so any length field with its high bit set — anything from
0x80000000 up — comes out of that expression as a negative number instead of a large
positive one. A single 4-byte field in a crafted or simply corrupted PNG could flip the parser's
idea of a chunk's length from "very large" to "negative," and the code that used it,
end = i + 12 + len, would then compute an end offset that has no sane relationship to
the actual file: too small, too large, or in the wrong direction, depending on the exact bytes.
Nothing downstream was checking that offset against the real buffer size before treating it as
trustworthy, which is the kind of gap that turns "the length field lied" into "we sliced or
allocated based on a lie."
The fix: force it unsigned, then check it against reality
The fix is two lines, both still in pngChunks(). First, the length read gets an
>>> 0 appended — JavaScript's unsigned-right-shift-by-zero idiom, which forces
the bit pattern to be interpreted as unsigned regardless of what the signed bitwise-OR produced.
That alone fixes the sign flip: a length of 0x80000000 now reads as
2,147,483,648, not -2147483648. But an unsigned-but-still-wrong number is still wrong,
so the second half of the fix doesn't trust the corrected value either — it computes
end = i + 12 + len exactly as before, then immediately checks
if (end > bytes.length) throw new Error('corruptImage'), before that chunk is kept
or handed to anything else. The order matters: the bounds check happens before any subarray copy,
any allocation sized by len, any assumption that the chunk is real. A file that fails
this check doesn't get partially processed and then blow up somewhere less predictable later — it
gets rejected cleanly, in one place, the first time its numbers stop making sense.
A tiny archive that claims to be enormous
The second case is a different shape of the same underlying problem: trusting a size claim
before verifying it's plausible. docx, xlsx, pptx, odt, ods, and odp files are all ZIP archives
under the hood, and scrubOffice() unzips one to blank out the PII-bearing property
parts (docProps/core.xml, app.xml, custom.xml) before
re-zipping it. ZIP's per-entry header carries the entry's uncompressed size as a plain declared
number, written by whatever produced the archive — nothing forces that number to be honest, and
extreme compression ratios are entirely legal within the format. A "zip bomb" is a file built to
exploit exactly that: a download that's a few kilobytes on disk can be constructed to declare (and
actually decompress to, if you let it) many gigabytes or more. Handed to a naive unzip call, that's
enough to exhaust the memory of the tab trying to clean what looked like an ordinary attachment.
The fix: check the claim before doing the work it implies
NearScrub uses fflate for zip handling, and unzipSync exposes a
filter(file) callback that fires once per entry, before that entry is actually
inflated — at that point file.originalSize is only the number the archive's own
(still-compressed) header claims, not a measured result. scrubOffice() uses exactly
that timing: it keeps a running total across entries, and the moment
total exceeds MAX_OFFICE_UNZIPPED_BYTES — 2 GB, a budget real docx/xlsx/pptx
files essentially never approach even with embedded media — it sets a bomb flag and
returns false from the filter to skip decompressing that entry, then throws
archiveTooLarge once unzipSync returns. The check costs nothing beyond an
addition and a comparison per entry; it happens strictly before any actual inflate work, so a file
that declares an absurd total gets rejected on the strength of its own header, not after the
browser has already spent a large chunk of its address space finding out the hard way.
Same shape, twice: verify before you commit resources
Both fixes are instances of one rule: numbers that come from inside an untrusted file — a chunk
length, a declared uncompressed size — are claims, not facts, until checked against something the
parser can independently verify (the real buffer length, a fixed sane budget). The PNG case checks
a length against the actual bytes available before treating a chunk as real. The Office case checks
a running declared-size total against a fixed ceiling before letting decompression happen at all.
Neither fix changes behavior for a well-formed file in any observable way — the 2 GB budget and the
bounds check are both well outside anything a real photo, screenshot, or office document produces.
What they change is what happens to the one input that was never going to be well-formed, whether
that file arrived corrupted by accident or built deliberately: instead of the tab discovering the
problem partway through an allocation or a slice it can't safely finish, it gets a clean, named
error — corruptImage, archiveTooLarge — thrown from the one place that
already knows exactly what went wrong.
Why bother, with no server in the picture
Neither of these bugs could ever have taken down infrastructure — NearScrub doesn't have any to take down. But "no server" doesn't mean "no consequence." The person hitting either of these paths is looking at their own browser tab, trying to strip the GPS coordinates out of a photo before sending it to someone, or the author metadata out of a document before publishing it — exactly the kind of privacy-motivated moment where a frozen tab or a silent wrong-size output is the worst possible failure. A local, single-user, no-upload tool is a smaller attack surface than a server that parses files from strangers, but "smaller" isn't "zero," and a file format parser that assumes its input is well-behaved will eventually meet one that isn't — corrupted by a bad transfer, or, if this app is ever pointed at a file from someone else, deliberately hostile. Both are worth failing on cleanly rather than badly.