@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
Maintainers
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-uploadThe 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); // nullMagic 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/pdfor a zip type, the bytes have to agree, or it is refused.reasonis 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'; sandboxsandbox 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 test25 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
