NearScrub

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.

Sponsored
← NearScrub

This page shows ads only if you consent.