Dev.to · 5 min read

I built a QR code phishing scanner for our free API — and ended up writing a PNG decoder from scratch to make it work.

I built a QR code phishing scanner for our free API — and ended up writing a PNG decoder from scratch to make it work.

** ** The problem QR code phishing ("quishing") is one of the fastest-growing phishing vectors right now — Microsoft's 2026 report puts the year-over-year growth at 146% in Q1 alone, and QR codes now account for roughly 12% of all phishing attacks, up from under 1% in 2021. The attack specifically slips past text-based email filters, since the malicious URL is hidden inside an image. I wanted to add an endpoint to Presend's API that decodes a QR code from an uploaded image and, if it points to a URL, checks that URL against a malware/phishing database — decode plus reputation check in one call. The API runs on Cloudflare Workers, which meant no Canvas, no Node's Buffer, no filesystem, and a strict script-size budget. That constraint is what turned a "just wire up a library" task into a real build. The easy half: JPEG jsQR does the actual QR decoding — pure JS, zero dependencies, works fine once you get pixel data into it. For JPEG, jpeg-js is also pure JS and, conveniently, ships a useTArray option that swaps its internal Buffer.alloc() for Uint8Array, sidestepping the one Node-specific call in the whole library. Vendored, adapted the export statement, done. PNG was not that easy. Why the obvious PNG library doesn't work here pngjs is the standard choice for PNG decoding in JS. It also require('zlib') internally for decompression — Node's built-in module, not available in Workers. Dead end. Cloudflare Workers does expose one very useful native API though: DecompressionStream('deflate'). PNG's pixel data is deflate-compressed, so I didn't need a bundled zlib at all — just the browser-standard streams API, already available in the runtime. That covers decompression. The rest — parsing PNG's chunk structure, reversing the per-scanline filtering, and reassembling pixels — needed writing. What actually goes into decoding a PNG A PNG file is a signature followed by a sequence of chunks (IHDR for dimensions/color type, one or more IDAT chunks holding the compressed pixel data, IEND to close it out). After inflating the IDAT data, each scanline starts with a filter-type byte (0–4: None, Sub, Up, Average, Paeth) that tells you how that row's bytes were transformed relative to already-decoded neighboring bytes, to help zlib compress more effectively. You reverse it with straightforward integer math — nothing exotic, just easy to get subtly wrong. I tested this against a real PNG with known pixel values at several points (corners and center) and got an exact match — so the core decoder was solid. Then I fed it a real QR code image and got: Only 8-bit PNGs are supported (got 1-bit). The bug that would have made the whole feature useless Most QR code generators — including the Python qrcode library I used to build test fixtures — output 1-bit PNGs by default. Black and white doesn't need 8 bits per pixel; 1 bit is the natural, efficient encoding. I'd built and tested my decoder entirely against 8-bit images and never hit this path. This wasn't a corner case. It was the default case for the exact file type this endpoint exists to decode. If I'd shipped it as-is, the feature would have failed on most real-world QR codes. Fixing it meant handling sub-byte pixel packing: for bit depths below 8, multiple pixels are packed into a single byte, MSB-first, with each scanline's bit-packed data padded to a whole byte at the end. There's also a spec detail worth knowing if you ever write this yourself — for filter reconstruction math, the "bytes per pixel" distance-back value is defined as 1 for any bit depth under 8, regardless of the real bit depth, per the PNG spec. jsQR ships as a webpack UMD bundle. Its environment-detection fallback does roughly: js })(typeof self !== 'undefined' ? self : this, function() { ... }); Running this through plain Node in an ESM context crashes — Node doesn't define a global self, and top-level this in an ES module is undefined, so the assignment throws. My first instinct was to patch the file to work around it. Then I remembered: this isn't running in Node. Cloudflare Workers do expose self as a real global (same as a browser or a Service Worker). I tested directly against the actual Workers runtime instead of trusting the local Node error, and it worked without any changes. The lesson: a local Node script and a Workers runtime are different environments, and it's worth checking which one you're actually debugging before "fixing" something that isn't broken where it counts. Where it landed POST /api/qr-scan now decodes JPEG or PNG QR codes (including the 1-bit case) and, when the content is a URL, checks it against URLhaus in the same call. Free, no signup, no API key — same as the rest of Presend's API. Code's on GitHub if you want to see the actual decoder: github.com/presendapp/presend — vendor/png-decoder.js and functions/api/qr-scan.js specifically. Curious if anyone else has hit the Workers-vs-Node global-scope gap in a different library — feels like the kind of thing that'll keep biting people as more JS runtimes diverge from the Node-shaped assumptions most npm packages still make.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Cybersecurity News