@competentgroove/secure-upload
v1.0.2
Published
Production-ready secure file upload validation and sanitization using magic-byte detection
Readme
secure-upload
Production-ready secure file upload validation and sanitization for Node.js.
secure-upload uses magic-byte detection to determine the real type of uploaded files — not file extensions, not Content-Type headers. It then applies type-specific security rules to catch dangerous payloads before they reach your storage or processing pipeline.
Table of Contents
- Why secure-upload?
- Threat Coverage
- Installation
- Quick Start
- API Reference
- Configuration Reference
- Framework Integrations
- Validation Result Format
- Issue Codes
- Validation Pipeline
- Plugin System
- Security Recommendations
- Roadmap
Why secure-upload?
Most file validation libraries rely on MIME type headers or file extensions. Both are trivially spoofable by attackers:
# Extension spoofed: malware.exe → document.pdf
# MIME spoofed: Content-Type: image/png (but file is actually a PHP shell)secure-upload reads the first bytes of the file (the magic bytes) to determine the true format, then applies type-specific security rules. Spoofing magic bytes in a way that also fools parsers is much harder.
What secure-upload catches
| Threat | Detection |
|--------|-----------|
| MIME spoofing | Magic-byte detection vs. client MIME |
| Extension spoofing | Detected ext vs. filename ext |
| PDF with JavaScript | /JavaScript, /JS keyword scan |
| PDF with auto-execute | /OpenAction, /Launch scan |
| SVG XSS | <script>, on*=, foreignObject |
| SVG SSRF | External href references |
| CSV formula injection | Leading =, +, -, @ detection |
| ZIP bomb | Entry count, uncompressed size, ratio |
| ZIP path traversal | ../ in entry names |
| Nested archives | Configurable reject |
| Office macros | vbaProject.bin detection |
| JSON prototype pollution | __proto__, constructor key detection |
| Malformed images | JPEG/PNG/GIF/WebP header validation |
Installation
npm install @competentgroove/secure-uploadNo required runtime dependencies. Framework middleware is optional and uses peer dependencies.
# For Express middleware
npm install express multer
# For Fastify plugin
npm install fastify @fastify/multipart
# For Koa middleware
npm install koa koa-bodyNode.js 18+ required.
Usage Styles
secure-upload works in CommonJS, ESM, and TypeScript — pick whichever fits your stack.
CommonJS (plain .js, no TypeScript needed)
// Import only what you need — no class instantiation required
const { validatePdf, validateImage, validateSvg, validateCsv, validateJson, validateAny } = require('@competentgroove/secure-upload');
// Validate a PDF
const result = await validatePdf(fs.readFileSync('upload.pdf'), {
allowJavaScript: false,
rejectEncrypted: true,
});
if (!result.valid) console.error(result.detectedIssues);
// Validate + sanitize SVG in one call
const svgResult = await validateSvg(buffer, { allowScripts: false, sanitize: true });
const safeContent = svgResult.sanitized ?? buffer; // sanitized when threats were removed
// Auto-detect type and validate with a single call
const anyResult = await validateAny(buffer, {
allowedMimeTypes: ['application/pdf', 'image/png', 'image/jpeg'],
pdf: { allowJavaScript: false },
svg: { allowScripts: false },
});ESM / TypeScript
import { validatePdf, validateImage, validateAny } from '@competentgroove/secure-upload';
// same API, full type safetyClass-based (for shared config across many requests)
import { createValidator } from '@competentgroove/secure-upload';
const validator = createValidator({
allowedMimeTypes: ['application/pdf', 'image/png'],
pdf: { allowJavaScript: false },
});
const result = await validator.validateBuffer(buffer);Quick Start
import { createValidator } from '@competentgroove/secure-upload';
const validator = createValidator({
allowedMimeTypes: [
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
],
maxFileSize: 10 * 1024 * 1024, // 10 MB
pdf: {
allowJavaScript: false, // reject /JavaScript
allowOpenAction: false, // reject auto-execute on open
allowLaunchAction: false, // reject program launch
allowEmbeddedFiles: false, // reject embedded file attachments
rejectEncrypted: true, // reject encrypted PDFs
},
svg: {
allowScripts: false,
allowEventHandlers: false,
sanitize: true, // return cleaned buffer in result.sanitized
},
csv: {
preventFormulaInjection: true,
escapeFormulas: true, // return escaped buffer in result.sanitized
},
zip: {
maxEntries: 100,
maxUncompressedSize: 200 * 1024 * 1024, // 200 MB
maxCompressionRatio: 100,
rejectNestedArchives: true,
},
json: {
maxDepth: 30,
maxKeys: 5000,
detectPrototypePollution: true,
},
});
// Validate a Buffer
const result = await validator.validateBuffer({
buffer: fileBuffer,
filename: 'upload.pdf', // used for extension hint
mimeType: 'application/pdf', // client-supplied (compared, not trusted)
});
if (!result.valid) {
console.error('Rejected:', result.detectedIssues);
} else {
console.log('Accepted:', result.mime, result.ext);
// If sanitization was enabled, use result.sanitized
const safeContent = result.sanitized ?? fileBuffer;
}API Reference
Standalone functions
All functions accept either a plain Buffer or { buffer, filename?, mimeType? }.
| Function | Config type | Auto-sanitizes |
|----------|-------------|----------------|
| validatePdf(input, config?) | PdfConfig | No |
| validateImage(input, config?) | ImageConfig | No |
| validateSvg(input, config?) | SvgConfig | Yes — when sanitize: true |
| validateCsv(input, config?) | CsvConfig | Yes — when escapeFormulas: true |
| validateTxt(input, config?) | ValidatorConfig | No |
| validateZip(input, config?) | ZipConfig | No |
| validateOffice(input, config?) | OfficeConfig | No |
| validateJson(input, config?) | JsonConfig | No |
| validateAny(input, config?) | ValidatorConfig | Format-dependent |
// CommonJS
const { validatePdf, validateSvg, validateCsv, validateJson, validateAny } = require('@competentgroove/secure-upload');
// Also available from dedicated subpath (tree-shakeable)
const { validatePdf } = require('@competentgroove/secure-upload/functions');// TypeScript / ESM
import { validatePdf, validateSvg, validateAny } from '@competentgroove/secure-upload';Sanitization
When a sanitizer is available and active (e.g. SVG with sanitize: true, CSV with escapeFormulas: true), the returned ValidationResult includes a sanitized buffer:
const result = await validateSvg(buffer, { allowScripts: false, sanitize: true });
// Issues still listed in result.detectedIssues for audit trail
// Safe content available in result.sanitized (undefined if nothing was changed)
const safeBuffer = result.sanitized ?? buffer;createValidator(config?, extraValidators?)
Returns a FileGuard instance.
import { createValidator } from '@competentgroove/secure-upload';
const guard = createValidator(config, extraValidators);guard.validateBuffer(input)
// Accepts a raw Buffer or a BufferInput object
const result = await guard.validateBuffer(buffer);
const result = await guard.validateBuffer({
buffer,
filename: 'photo.jpg', // optional — used for extension check
mimeType: 'image/jpeg', // optional — compared against detected type
});guard.validateFile(filePath, options?)
Reads the file from disk, enforcing the maxFileSize limit without loading the entire file if it exceeds the limit.
const result = await guard.validateFile('/tmp/upload/abc123', {
filename: 'original-name.pdf',
mimeType: 'application/pdf',
});guard.validateStream(input)
Collects the stream into memory up to maxFileSize then validates.
// Accepts a Readable stream or a StreamInput object
const result = await guard.validateStream(readableStream);
const result = await guard.validateStream({
stream: readableStream,
filename: 'upload.png',
mimeType: 'image/png',
});Configuration Reference
All options are optional. Defaults are shown.
interface ValidatorConfig {
// Allowed MIME types (detected, not client-supplied). Empty = allow all.
allowedMimeTypes?: string[];
// Maximum file size in bytes. Default: 52,428,800 (50 MB)
maxFileSize?: number;
// Fail when detected MIME ≠ client-supplied MIME. Default: true
strictMimeCheck?: boolean;
// Fail when detected extension ≠ filename extension. Default: false
strictExtensionCheck?: boolean;
// Enable debug logging. Default: false
debug?: boolean;
pdf?: {
allowJavaScript?: boolean; // default: false
allowOpenAction?: boolean; // default: false
allowLaunchAction?: boolean; // default: false
allowEmbeddedFiles?: boolean; // default: false
allowXFA?: boolean; // default: false
allowRichMedia?: boolean; // default: false
allowAAAction?: boolean; // default: false
rejectEncrypted?: boolean; // default: false
};
svg?: {
allowScripts?: boolean; // default: false
allowEventHandlers?: boolean; // default: false
allowForeignObject?: boolean; // default: false
allowExternalReferences?: boolean; // default: false
sanitize?: boolean; // default: false — mutates content
};
csv?: {
preventFormulaInjection?: boolean; // default: true
escapeFormulas?: boolean; // default: false — prepends \t
maxLineLength?: number; // default: 1,000,000
};
zip?: {
maxEntries?: number; // default: 1000
maxUncompressedSize?: number; // default: 524,288,000 (500 MB)
maxCompressionRatio?: number; // default: 100
rejectNestedArchives?: boolean; // default: false
rejectEncrypted?: boolean; // default: false
};
image?: {
stripMetadata?: boolean; // default: false (requires sharp peer dep)
};
json?: {
maxDepth?: number; // default: 50
maxKeys?: number; // default: 10,000
detectPrototypePollution?: boolean; // default: true
};
office?: {
rejectMacros?: boolean; // default: true
rejectEmbeddedExecutables?: boolean; // default: true
};
}Framework Integrations
Express + multer
import express from 'express';
import multer from 'multer';
import { expressMiddleware } from '@competentgroove/secure-upload/middleware/express';
const upload = multer({ storage: multer.memoryStorage() });
app.post(
'/upload',
upload.single('file'),
expressMiddleware({
allowedMimeTypes: ['image/png', 'image/jpeg', 'application/pdf'],
pdf: { allowJavaScript: false },
}),
(req, res) => {
// req.fileValidation has the ValidationResult
res.json({ ok: true, mime: req.fileValidation.mime });
},
);
// Handle validation errors
app.use((err, req, res, next) => {
if (err.code === 'FILE_VALIDATION_FAILED') {
return res.status(422).json({ issues: err.results.flatMap(r => r.detectedIssues) });
}
next(err);
});Fastify
import Fastify from 'fastify';
import { secureUploadPlugin } from '@competentgroove/secure-upload/middleware/fastify';
const fastify = Fastify();
await fastify.register(secureUploadPlugin, {
allowedMimeTypes: ['image/png', 'image/jpeg'],
});
fastify.post('/upload', async (request, reply) => {
const data = await request.file();
const buffer = await data.toBuffer();
const result = await request.validateFile(buffer, { filename: data.filename, mimeType: data.mimetype });
if (!result.valid) {
return reply.code(422).send({ issues: result.detectedIssues });
}
return { ok: true };
});Koa
import Koa from 'koa';
import koaBody from 'koa-body';
import { koaMiddleware } from '@competentgroove/secure-upload/middleware/koa';
const app = new Koa();
app.use(koaBody({ multipart: true }));
app.use(koaMiddleware({
fileField: 'upload',
throwOnFail: true,
allowedMimeTypes: ['image/png', 'image/jpeg'],
}));
app.use(async (ctx) => {
// ctx.state.fileValidation has the result
ctx.body = { ok: true, result: ctx.state.fileValidation };
});Validation Result Format
interface ValidationResult {
valid: boolean; // false if any non-info issue was found
mime: string; // detected MIME type (from magic bytes)
ext: string; // detected extension
format: string; // human-readable format name (e.g., "PDF")
size: number; // file size in bytes
clientMime?: string; // client-supplied MIME (normalized)
clientExt?: string; // client-supplied extension (from filename)
detectedIssues: ValidationIssue[];
sanitized?: Buffer; // present when sanitizer was applied and changed content
}
interface ValidationIssue {
code: IssueCode; // machine-readable error code
severity: 'info' | 'low' | 'medium' | 'high' | 'critical';
message: string; // human-readable description
detail?: string; // additional context
}Issue Codes
| Code | Severity | Description |
|------|----------|-------------|
| MIME_MISMATCH | high | Client MIME ≠ detected MIME |
| EXTENSION_MISMATCH | medium | Filename ext ≠ detected ext |
| UNKNOWN_TYPE | high | File type unrecognizable |
| TYPE_NOT_ALLOWED | high | MIME not in allowedMimeTypes |
| FILE_TOO_LARGE | high | Exceeds maxFileSize |
| FILE_EMPTY | high | Zero-byte file |
| PDF_JAVASCRIPT | high | /JavaScript or /JS detected |
| PDF_OPEN_ACTION | high | /OpenAction detected |
| PDF_LAUNCH_ACTION | critical | /Launch (executes programs) |
| PDF_EMBEDDED_FILE | medium | /EmbeddedFile detected |
| PDF_XFA | high | XFA form detected |
| PDF_RICH_MEDIA | medium | Flash/video embed detected |
| PDF_AA_ACTION | medium | Additional Actions detected |
| PDF_ENCRYPTED | info/medium | Encrypted PDF (info by default) |
| PDF_MALFORMED | high | Invalid PDF structure |
| PDF_TRUNCATED | medium | Missing %%EOF |
| IMAGE_MALFORMED | high | Invalid image structure |
| IMAGE_HEADER_INVALID | high | Bad magic bytes / header |
| IMAGE_TRUNCATED | medium | File too short |
| SVG_SCRIPT | critical | <script> or javascript: URI |
| SVG_EVENT_HANDLER | high | Inline event handler (on*=) |
| SVG_FOREIGN_OBJECT | high | <foreignObject> element |
| SVG_EXTERNAL_REF | medium | External URL in href |
| SVG_MALFORMED | high | Not valid SVG |
| CSV_FORMULA_INJECTION | high | Leading =, +, -, @ |
| CSV_NULL_BYTES | medium | Null bytes in content |
| ZIP_BOMB | critical | Excessive size or ratio |
| ZIP_TOO_MANY_ENTRIES | high | Entry count exceeded |
| ZIP_NESTED_ARCHIVE | medium | Archive inside archive |
| ZIP_MALFORMED | high | Invalid ZIP structure |
| OFFICE_MACRO | high | VBA macro detected |
| OFFICE_EMBEDDED_EXEC | critical | Embedded executable |
| OFFICE_MALFORMED | high | Invalid Office file |
| JSON_INVALID | high | JSON parse failure |
| JSON_DEPTH_EXCEEDED | high | Nesting depth limit |
| JSON_KEY_LIMIT_EXCEEDED | medium | Too many keys |
| JSON_PROTOTYPE_POLLUTION | critical | __proto__/constructor keys |
Validation Pipeline
Input (buffer / file / stream)
│
▼
[Size check] ──── too large ──▶ FILE_TOO_LARGE
│
▼
[Magic-byte detection] ──── unrecognized ──▶ UNKNOWN_TYPE
│
▼
[Allowlist check] ──── not allowed ──▶ TYPE_NOT_ALLOWED
│
▼
[MIME/extension comparison] ──── mismatch ──▶ MIME_MISMATCH / EXTENSION_MISMATCH
│
▼
[Type-specific validator(s)] ──── issues ──▶ PDF_JAVASCRIPT, SVG_SCRIPT, ZIP_BOMB, ...
│
▼
[Sanitizer] (optional) ──▶ result.sanitized
│
▼
ValidationResult { valid, mime, ext, detectedIssues, sanitized? }Plugin System
Register custom validators alongside the built-ins:
import { createValidator } from '@competentgroove/secure-upload';
import type { FileValidator, ValidationIssue, ValidatorConfig } from '@competentgroove/secure-upload';
const myValidator: FileValidator = {
supportedMimes: ['application/x-custom-format'],
async validate(buffer: Buffer, config: ValidatorConfig): Promise<ValidationIssue[]> {
const issues: ValidationIssue[] = [];
// Your logic here
return issues;
},
// Optional: sanitize and return cleaned buffer
async sanitize(buffer: Buffer, config: ValidatorConfig): Promise<Buffer> {
return buffer; // or return modified buffer
},
};
const guard = createValidator({ allowedMimeTypes: ['application/x-custom-format'] }, [myValidator]);Planned plugin hooks (v2)
ClamAV— stream files through clamd for AV scanningYARA rules— match custom threat signaturesCloud AV— VirusTotal, Microsoft Defender Cloud, etc.PDF sanitizer— strip dangerous elements from valid PDFsImage re-encoder— pipe through sharp to strip metadata and re-encode
Security Recommendations
Beyond secure-upload, apply these defence-in-depth measures:
- Store uploads outside the webroot — never in
public/orstatic/. - Serve user uploads from a separate origin (or S3/CDN) with
Content-Disposition: attachmentto prevent browser execution. - Use
Content-Security-Policyheaders to limit script execution sources. - Never preserve the original filename on disk — generate a UUID for storage.
- Quarantine before processing — move files to a staging area, validate, then promote to production storage only if they pass.
- Set short TTLs on upload endpoints and rate-limit by IP.
- For SVG files that will be rendered inline, use the
sanitize: trueoption and additionally serve them withContent-Type: text/plainor from an isolated origin. - For PDFs that will be rendered in-browser, consider converting to images or using a sandboxed viewer.
- Log all rejected files — failed validations are high-value security events.
- Run secure-upload in a Worker Thread or separate process for untrusted file processing to limit blast radius if a parser crashes.
Roadmap
- [ ] ZIP64 support (files > 4 GB)
- [ ] Streaming validators (avoid loading large files fully)
- [ ] ClamAV integration
- [ ] YARA rule matching
- [ ] PDF sanitization (strip JS without rejecting the file)
- [ ] Image re-encoding via sharp (metadata stripping)
- [ ] NestJS pipe integration
- [ ] Content-Disarm-and-Reconstruct (CDR) pipeline
License
MIT
