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

@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.

CI npm License: MIT TypeScript

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?

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-upload

No 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-body

Node.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 safety

Class-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 scanning
  • YARA rules — match custom threat signatures
  • Cloud AV — VirusTotal, Microsoft Defender Cloud, etc.
  • PDF sanitizer — strip dangerous elements from valid PDFs
  • Image re-encoder — pipe through sharp to strip metadata and re-encode

Security Recommendations

Beyond secure-upload, apply these defence-in-depth measures:

  1. Store uploads outside the webroot — never in public/ or static/.
  2. Serve user uploads from a separate origin (or S3/CDN) with Content-Disposition: attachment to prevent browser execution.
  3. Use Content-Security-Policy headers to limit script execution sources.
  4. Never preserve the original filename on disk — generate a UUID for storage.
  5. Quarantine before processing — move files to a staging area, validate, then promote to production storage only if they pass.
  6. Set short TTLs on upload endpoints and rate-limit by IP.
  7. For SVG files that will be rendered inline, use the sanitize: true option and additionally serve them with Content-Type: text/plain or from an isolated origin.
  8. For PDFs that will be rendered in-browser, consider converting to images or using a sandboxed viewer.
  9. Log all rejected files — failed validations are high-value security events.
  10. 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