An upload handler written from scratch usually trusts three things the uploader controls:
the file name, the declared type and the path it is saved under. Each one is a known
attack - a .svg or .html served inline runs script on your origin, a name like
../../app.js writes outside the upload folder, and a “PNG” can be anything.
These four functions keep that logic in one place:
import { inspectUpload, storageName, resolveInside, downloadHeaders } from "@enigmax/utils/server";
const { kind, mime } = inspectUpload(bytes, { allow: ["png", "jpeg", "webp", "pdf"], maxBytes: 10_000_000 });
const path = resolveInside(UPLOAD_ROOT, storageName(kind));
await writeFile(path, bytes);
// Serving it back:
return new Response(body, { headers: downloadHeaders({ name: file.name, type: file.mime, inline: true }) });
Installation
Adds the package and prints the import to use. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.
enigma add safe-uploadInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.
1 Install the dependencies
2 Use it
Import from @enigmax/utils/server.
Node only, no dependencies. Import it from the /server entry.
The four steps
inspectUpload(bytes, { allow, maxBytes })decides the type from the file’s first bytes. The name and the declared type are not consulted, so there is nothing to lie about.allowis required: there is no safe default list. SVG and HTML are recognised so you can refuse them.storageName(kind)is a random UUID plus the extension the bytes earned. Keep the original name in your database for display, never on disk.resolveInside(root, ...segments)joins segments underrootand throws when the result would leave it (.., an absolute segment, a drive letter).downloadHeaders({ name, type, inline })setsX-Content-Type-Options: nosniff, a sandboxingContent-Security-Policy, and aContent-Dispositionwith a sanitised name.inlineis honoured only for types a browser renders passively (images, PDF, audio, video, plain text). Anything that could run, SVG and HTML included, is always sent as a download.
A refused upload throws UploadRefusedError with a reason: empty, too-large,
unknown-type or type-not-allowed. Map it to a message the uploader can act on:
reason |
Message |
|---|---|
too-large |
“The file is over 10 MB.” |
type-not-allowed |
“Upload a PNG, JPEG, WebP or PDF.” |
unknown-type |
“That file type isn’t supported.” |
resolveInside works on the path text only. Do not let uploads create symlinks inside the
upload folder.