npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@weggle/safe-upload

v1.0.0

Published

Decide what an uploaded file actually is from its bytes, store it under a name that cannot execute, and serve it back with headers that keep it inert.

Downloads

192

Readme

safe-upload

Decide what an uploaded file actually is from its bytes, store it under a name that cannot execute, and serve it back with headers that keep it inert.

No dependencies. Node 18+.

npm install @weggle/safe-upload

The threat model

If you accept file uploads and serve them back, you are running a web server whose content is written by strangers. Three things go wrong, usually in this order.

Nothing the client tells you is evidence. The filename, the extension inside it, and the multipart Content-Type are all strings the uploader chose. Someone uploads a file of HTML, calls it avatar.png, and labels it image/png. If you believe any of that, you have stored a script.

The extension on disk decides what the browser executes. express.static and every other static file server derive Content-Type from the file's extension. It does not matter what you validated at upload time — if a file reaches disk called something.html, it is served as a live document on your origin. Same-origin, so it can read cookies scoped to that host and call your API as the visitor.

nosniff does not save you from SVG. An SVG genuinely is image/svg+xml; the declared type is honest, so there is nothing for nosniff to prevent. Navigate a browser straight at one and any <script> inside runs on the serving origin. The fix is not a better type — it is a sandbox.

A common non-fix is to serve uploads from a separate hostname. That helps, and it is worth doing, but "separate hostname" is only a boundary if nothing else trusts it. If your CDN host can read a cookie scoped to .example.com, it is not a boundary.

What this does

Three jobs, matching the three problems.

const { sniff, validateUpload, staticSetHeaders } = require('@weggle/safe-upload');

1. sniff(buffer) — what the bytes say

sniff(buffer);            // { format: 'png', mime: 'image/png' }
sniff(htmlBuffer);        // { format: 'html', mime: 'text/html' }
sniff(randomBinary);      // null

Magic numbers for the formats that have them, container inspection for RIFF and ISO-BMFF (WebP, WAV, AVI, MP4, MOV, M4A), and a deliberately generous markup sniffer that looks past a BOM and leading whitespace.

Returns null when it does not know, and null means unknown, not safe.

2. validateUpload(buffer, originalName, declaredMime) — whether to store it, and as what

const result = validateUpload(buffer, file.originalname, file.mimetype);

if (!result.ok) return res.status(400).send(result.reason);

const storedPath = path.join(uploadDir, sha256(buffer) + result.storedExt);
{
  ok: true,
  format: 'svg',
  storedExt: '.bin',                       // NOT .svg
  storedMime: 'application/octet-stream',
  neutralised: true
}

Two rules, deliberately different in strength:

  • A claimed media type must be borne out by the bytes. If the upload says image/*, video/*, audio/*, application/pdf or a zip type, the bytes have to agree, or it is refused. reason is a sentence you can show a user.
  • Anything that would execute as a document is stored inert. HTML, SVG, XHTML and XML — by content or by extension — get storedExt: '.bin'. Keep the original filename in your database for display and downloads; only the path on disk changes.

What it does not do is reject unknown bytes. A general file host stores arbitrary binaries, and an allowlist of known signatures would refuse most of them. If you want an allowlist, check result.format yourself.

storedExt is always either empty, a plain .ext, or .bin — never a path separator, never .., regardless of what the uploader called the file.

3. setUserFileHeaders(res, mimeType, filePath) — how to serve it back

const { staticSetHeaders } = require('@weggle/safe-upload');

app.use('/uploads', express.static(uploadDir, { setHeaders: staticSetHeaders }));

nosniff on everything. For document types, a sandbox CSP:

default-src 'none'; style-src 'unsafe-inline'; sandbox

sandbox with no allow-list drops the document into a unique opaque origin: no script, no forms, no same-origin access. Images and video are not sandboxed, because a blanket sandbox would break <img src> — which is how images are actually consumed.

Pass whichever you have. A static middleware knows only the path; a streaming handler usually knows the type it is about to declare. Both is fine.

Use them together

The two halves are independent on purpose — you can adopt either — but they are designed to agree. A file neutralised at upload time is no longer a document by name, so the serving side does not need to sandbox it, and if something ever reaches disk with an executing extension anyway, the serving side still catches it.

const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { validateUpload, staticSetHeaders } = require('@weggle/safe-upload');

const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25e6 } });

app.post('/upload', upload.single('file'), (req, res) => {
  const check = validateUpload(req.file.buffer, req.file.originalname, req.file.mimetype);
  if (!check.ok) return res.status(400).json({ error: check.reason });

  const name = crypto.createHash('sha256').update(req.file.buffer).digest('hex') + check.storedExt;
  fs.writeFileSync(path.join('uploads', name), req.file.buffer);

  // Keep the real name for display; it never touches the filesystem.
  res.json({ storedAs: name, originalName: req.file.originalname });
});

app.use('/uploads', express.static('uploads', { setHeaders: staticSetHeaders }));

What is out of scope

  • Archive contents. Nothing here extracts anything, so zip traversal, entry counts and decompression bombs are not addressed. A declared zip is checked for a zip header and otherwise left whole. If you extract archives, you need separate protection.
  • Malware scanning. This tells you a file is a Windows executable. It does not tell you whether it is a malicious one.
  • Image re-encoding. Stripping EXIF or defusing decoder bugs means re-encoding through an image library, which is a much heavier dependency than this package wants to be.
  • Size limits and rate limiting. Your upload middleware's job.

API

| Export | Purpose | |---|---| | sniff(buffer) | { format, mime } or null | | isFormat(buffer, format) | boolean — do the bytes match this format | | validateUpload(buffer, name, mime) | storage decision (see above) | | safeExtname(name) | extension with no separators, or '' | | setUserFileHeaders(res, mime, path) | apply safe response headers | | staticSetHeaders(res, path) | setHeaders hook for express.static | | SIGNATURES, STRICT_FAMILIES | the rule tables, if you want to inspect or extend them | | EXECUTING_EXTENSIONS, EXECUTABLE_DOCUMENT_FORMATS, NEUTRAL_EXT | storage policy constants | | DOCUMENT_TYPES, DOCUMENT_EXTENSIONS, SANDBOX_CSP | serving policy constants |

Tests

npm test

25 tests, and they are written against the security properties rather than the happy path — HTML disguised as a PNG, an SVG that must never keep its extension, markup hidden behind a BOM, filenames carrying path separators, and the rule that images stay embeddable.

License

MIT