@aria-framework/uploads
v0.4.0
Published
Aria App Framework — uploads module. The safe half of accepting a file over HTTP: the multer gate bound to a CSRF check that cannot be omitted, orphan cleanup on rejected requests, magic-byte verification, and MIME presets. The destination stays the app's
Readme
@aria-framework/uploads
The safe half of accepting a file over HTTP. The destination is not here and must not be — one consumer writes attachments to a filesystem, another writes documents into a field-encrypted database column, a third writes restore archives beside the data directory. What they share is everything before the bytes land and everything after a request is rejected.
const uploads = require('@aria-framework/uploads');
const attachments = uploads.createUploader({
dir: config.uploadsDir,
field: 'attachments',
maxFiles: 20,
maxBytes: 100 * 1024 * 1024,
allow: uploads.PRESETS.ATTACHMENTS,
prefix: 'att-',
csrf: csrfUtil.doubleCsrfProtection, // REQUIRED
handledFlag: 'attachmentsHandled'
});
router.post('/reply', requirePermission('tickets.reply'), ...attachments.accept, handler);Three mechanisms, each of which was a comment before it was code
1. CSRF runs after multer, and must still run. The token is inside the multipart body, so
nothing can verify it until multer has parsed the body — which means the global CSRF gate has to
skip multipart routes, and each such route has to check CSRF itself. One consumer documented
that in a comment ending "adding a path here WITHOUT that second half opens a CSRF hole". A
comment is not a mechanism. Here csrf is required and accept is the pair, in order, so a
route cannot take the gate without the check.
The one legitimate exception is named, not implied. A route authenticated by a Bearer token has nothing for CSRF to prevent — a browser attaches a cookie automatically, but an attacker's page cannot make it send a JWT. Forcing such a route to pass a no-op would mean writing a lie that looks exactly like the bug this module exists to stop, so instead:
csrf: uploads.CSRF_NOT_NEEDED_BEARER_AUTHA Symbol, so a stray default or a truthiness bug cannot become it, and greppable — one search
finds every route that skips, with the justification in the name. If the route is authenticated
by a cookie, this is the wrong constant and the answer is a real CSRF middleware.
2. A rejected request has already written the file. By the time CSRF refuses, multer has
written up to maxBytes to disk, and the handler that would have cleaned up never runs. Repeat
the request and the volume the app needs to keep running fills up. Cleanup is therefore flagged
on res.on('finish'), which fires either way; the handler sets handledFlag once it owns the
file.
3. The upload middleware never short-circuits. It always calls next() and reports failure
on req.uploadError. Calling next(err) would skip the CSRF check after it, turning a rejected
upload into an unauthenticated request.
When NOT to use this
If your route's CSRF token travels in a HEADER rather than the multipart body, you do not need
this module's central guarantee. One consumer posts uploads with fetch() and an
x-csrf-token header, so the global CSRF gate validates it before the route runs and multer
parsing the body afterwards is harmless. That arrangement needs no exemption list and no
re-applied check — it is simply better, and an app that can do it should.
If you can DERIVE the type from the bytes, do that instead of validating a claimed one. The
same consumer reads the magic bytes and names the type itself, cross-checking the extension for
the formats magic alone cannot separate (.doc and .docx are OLE and ZIP containers). It
never stores a client-supplied type at all. verify() below is the weaker design: it checks
that a claim is consistent with the content, which still lets an unrecognised file through if
nothing declared it.
This module is for the case where the token is inside the multipart body, where files land on disk, or where both.
verify() is separate from the gate, and after it
The gate checks what the client said; verify() checks what it sent, because magic
bytes cannot be read until the file exists. Pass @aria-framework/kit's fileSniff:
const { kept, rejected } = uploads.verify(req.files, { sniff: fileSniff });It returns both rather than throwing: three good files and one bad should keep three, and the caller decides how to say so. Rejected files are removed immediately rather than left for a sweep.
The rest
sweep(dir, prefix, maxAgeMs)— orphans from attempts that never completed. Only files carrying the prefix, only when older than the age; a file that is not ours is never touched.hashFile(path)— sha256, for an audit trail.PRESETS— named MIME allow-lists (IMAGES,DOCUMENTS,AUDIO_VIDEO,ARCHIVES,ATTACHMENTS,PAPERWORK). A list, not a policy: pass your own set freely.allow: nullmeans "anything", which is a legitimate choice for opaque binary rather than an omission.
Notes
multer is an optional peer, required lazily with a message that names the fix. memory: true
keeps bytes in RAM for a database-column destination — do not reuse a disk path's limits
there; 100 MB on disk is fine and 100 MB in memory is an OOM.
The client filename never lands on disk. It is a path-traversal primitive and an
extension-spoofing one, so what is stored is prefix + 16 random bytes + a sanitised extension,
with originalname kept for the audit.
