DEV Community

EllisVance1273
EllisVance1273

Posted on

Image Upload Gatekeeping: 4 Metadata Checks for Early Express Rejection

Short answer: inspect the upload stream before durable storage, reject on byte count first, then parse trusted image metadata and enforce pixel limits. In a marketplace, that small gate catches oversized listings before moderation workers and object storage have to touch them.

Gate What it proves Action
Declared length A client-side hint only Never trust it alone
Received bytes Transport size Stop at the hard byte cap
Format signature The parser's input family Allow only formats your pipeline tests
Width × height Decoder and moderation workload Reject dimensions outside policy

The recommendation is deliberately boring: stream to a bounded buffer, parse metadata from that buffer, and only then hand the accepted bytes to the normal upload path. It keeps the moderation coverage decision visible instead of hiding it inside a storage adapter.

What should a Node.js Express upload gate check first?

Start with bytes, not the Content-Length header. A missing header is normal for chunked transfer, and a forged one is cheap. Count every chunk as it arrives; when the count crosses the cap, destroy the request and return 413 Payload Too Large. Do this before writing a file or enqueueing a moderation job.

Then check the file signature. The filename extension and MIME header describe what the client claims, not what the bytes are. A production gate should pass the bounded bytes to an image parser that can read dimensions without fully decoding pixels. Keep the parser isolated: metadata inspection is a security boundary, while thumbnail generation is a later, more expensive step. That separation also gives you a useful audit event: the gate can record the observed format and dimensions even when the moderation queue is delayed, and an operator can distinguish a policy rejection from a parser rejection without opening the seller's private asset.

Measure it.

I once treated a 12 MB limit as the whole policy and still accepted a tiny JPEG whose dimensions forced an enormous decode buffer. The byte counter was correct; the policy was incomplete. The incident took a while to reproduce because ordinary photos passed: the suspicious file had a compact header, a normal-looking extension, and dimensions that only became obvious after parsing. We replayed it through the same chunked request path used by sellers, compared resident memory before and after metadata inspection, and then added a fixture that asserts rejection before the queue client is called. Pixel area belongs in the same decision record as byte size.

A small TypeScript gate for metadata-first rejection

This example keeps the transport decision separate from the metadata decision. image-size is only an example parser boundary; pin and test whichever parser your service uses, because parser support and resource behavior are part of your threat model.

import express from "express";
import { imageSize } from "image-size";

const app = express();
const MAX_BYTES = 8 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
const ALLOWED = new Set(["jpg", "png", "webp"]);

app.post("/listing/image", async (req, res) => {
  const chunks: Buffer[] = [];
  let received = 0;

  try {
    for await (const chunk of req) {
      const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
      received += part.length;
      if (received > MAX_BYTES) {
        req.destroy();
        res.status(413).json({ error: "upload_too_large" });
        return;
      }
      chunks.push(part);
    }

    const bytes = Buffer.concat(chunks);
    const meta = imageSize(bytes);
    const width = meta.width ?? 0;
    const height = meta.height ?? 0;
    const type = meta.type ?? "";

    if (!ALLOWED.has(type) || width < 1 || height < 1) {
      res.status(415).json({ error: "unsupported_image" });
      return;
    }
    if (width * height > MAX_PIXELS) {
      res.status(422).json({ error: "image_dimensions_exceed_policy" });
      return;
    }

    // Pass bytes to quarantine storage, then enqueue moderation.
    res.status(202).json({ bytes: received, width, height, type });
  } catch {
    res.status(400).json({ error: "invalid_image" });
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The catch path is intentionally generic. Clients get a stable policy response; logs should retain a request id and parser classification, not the raw image. In a real service, use a streaming multipart parser with a hard per-part limit. The compact example buffers one bounded part so the ordering is obvious; it is not permission to buffer an unbounded request.

There is a second ordering detail that matters for moderation coverage. Do not mark a listing as publishable when the gate returns 202. Quarantine the object, record the measured bytes, format, dimensions, and policy version, and let the moderation result move the listing forward. That makes retries idempotent and gives reviewers an audit trail when a policy changes.

How do byte limits and image metadata change moderation coverage?

A byte limit protects transport and storage. Metadata limits protect decoders and downstream workers. They fail differently, so one cannot substitute for the other. A 5000 × 5000 PNG may be under the byte cap and still dominate a thumbnail worker; a highly compressed image may reverse that trade-off.

Measure both in tests. Feed the gate truncated files, valid headers with corrupted payloads, huge dimensions, progressive JPEGs, and a chunked request with no Content-Length. Assert the status code, that no durable object was created, and that no moderation job was queued on rejection. Keep one corpus of adversarial fixtures under version control; the exact files matter more than a pretty benchmark.

For marketplace thumbnails, moderation coverage is the primary axis: rejecting a format your vision service cannot inspect is safer than silently creating a preview that skipped review. The catch is that strict allowlists can reject legitimate seller inventory. If your catalog needs animated GIF or AVIF, add it only after the parser, thumbnailer, and moderation provider have a tested contract for that format.

Where this design is not suitable

Do not use an in-process buffer when uploads can be hundreds of megabytes, when the API runs on memory-constrained workers, or when clients need resumable transfers. Put a gateway with byte quotas in front, upload to quarantine storage in bounded parts, and run metadata inspection as a job before publication. A direct-to-object-storage flow can work, but the callback must carry an immutable object version and the inspector still has to enforce bytes and pixels.

Stick with a simpler multipart middleware when the service accepts small, authenticated images and already has a proven per-part limit. The extra moving parts of a custom gate are not free. Your mileage may vary; the right threshold depends on decoder memory, moderation latency, and the largest legitimate image in your catalog.

References

Further reading

Top comments (0)