惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threatpost
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Tailwind CSS Blog
J
Java Code Geeks
博客园_首页
Google Online Security Blog
Google Online Security Blog
Hugging Face - Blog
Hugging Face - Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
Intezer
P
Palo Alto Networks Blog
V
Vulnerabilities – Threatpost
雷峰网
雷峰网
O
OpenAI News
SecWiki News
SecWiki News
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
美团技术团队
N
News | PayPal Newsroom
Project Zero
Project Zero
Forbes - Security
Forbes - Security
IT之家
IT之家
A
Arctic Wolf
WordPress大学
WordPress大学
Jina AI
Jina AI
T
Tor Project blog
博客园 - 三生石上(FineUI控件)
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
Attack and Defense Labs
Attack and Defense Labs
博客园 - 聂微东
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Privacy International News Feed
Cloudbric
Cloudbric
G
GRAHAM CLULEY
博客园 - 叶小钗
H
Hacker News: Front Page
腾讯CDC
量子位
Help Net Security
Help Net Security
人人都是产品经理
人人都是产品经理
C
Cyber Attacks, Cyber Crime and Cyber Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
爱范儿
爱范儿
L
Lohrmann on Cybersecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recorded Future
Recorded Future
C
CERT Recently Published Vulnerability Notes

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
File Uploads in Node.js: Processing, Validation, and Storage in Production
The Practica · 2026-06-18 · via The Practical Developer

A CSV file that was actually a ZIP bomb landed in one of my inboxes recently. Not a malicious attacker. A well-intentioned product manager who exported a “CSV” from a third-party tool that quietly renamed a compressed archive. The upload handler read it into memory, checked the extension, called it valid, and wrote 2 GB of decompressed data to the disk before the container’s filesystem filled and the orchestrator killed the pod.

This is not an edge case. Every Node.js API that accepts file uploads is a target, whether it knows it or not. The attacker does not need a zero-day. They need a field on a form that accepts multipart data, a handler that trusts the content-type header, and a developer who did not know that multer defaults to storing files on disk with the original filename.

The fix is an upload pipeline with six stages: protocol limits, content-type validation, stream inspection, size enforcement, safe storage, and cleanup guarantees. This post covers all six with working code, starting from the naive handler and building up to a production-safe pipeline.

The naive handler that bites you

Most file upload tutorials stop at this pattern:

import multer from 'multer';

const upload = multer({ dest: 'uploads/' });

app.post('/api/upload', upload.single('file'), (req, res) => {
  res.json({ filename: req.file?.filename, size: req.file?.size });
});

This code is dangerous on its face. Here is what it does by default:

  • Accepts any content-type, including application/zip with a .pdf extension.
  • Stores files in a local directory with the original filename, meaning a file called ../../../etc/crontab overwrites system files if the destination path is resolved unsafely.
  • Reads the entire file into memory before your handler sees it (unless you configure it otherwise).
  • Gives you no control over the total upload size until the file has already been written.
  • Offers no way to reject a file mid-stream based on its actual content.

You are one misconfigured form away from a production incident. Let me show you the pipeline that prevents all of these.

Stage 1: Busboy limits at the protocol level

Multer is a wrapper around busboy, the same library that powers Express’s body-parser. Multer hides the limits that busboy exposes. Drop Multer for raw busboy, or at minimum pass explicit limits through Multer’s options:

import multer from 'multer';

const upload = multer({
  // busboy limits, passed directly
  limits: {
    fieldNameSize: 100,       // default 100 bytes
    fieldSize: 1024 * 100,    // 100 KB max per text field
    fields: 10,               // max non-file fields
    fileSize: 10 * 1024 * 1024, // 10 MB max per file
    files: 1,                 // max number of files
    headerPairs: 2000,        // default 2000, fine
  },
});

These limits are enforced by busboy at the stream level. If a client sends a multipart body that exceeds fileSize, busboy stops parsing and emits an error before the file payload is fully written. The client sees a 400 instead of a 413 minutes later after the entire file has been buffered.

The critical one is fileSize. Without it, a client can send a 50 GB file, and busboy will buffer it to disk until the OS says no. With it, the stream rejects at the boundary.

But limits alone do not validate content. A 10 MB file that is an executable renamed to .jpg passes the limit check. The next stages catch that.

Stage 2: Validate content type before the stream starts

The Content-Type header in the multipart part is client-sent and trivially spoofable. Use it as a hint, not a gate. The real check happens at the stream level by inspecting the file magic bytes.

The pattern is to use a writable stream that buffers the first few bytes, inspects them against known magic numbers, and then pipes the rest to storage. Here is a reusable contentValidator stream:

import { Transform } from 'node:stream';

type FileType = 'image' | 'pdf' | 'csv' | 'json' | 'zip' | 'unknown';

const MAGIC_BYTES: Record<FileType, Uint8Array[]> = {
  image: [
    new Uint8Array([0xFF, 0xD8, 0xFF]),             // JPEG
    new Uint8Array([0x89, 0x50, 0x4E, 0x47]),       // PNG
    new Uint8Array([0x47, 0x49, 0x46, 0x38]),       // GIF
    new Uint8Array([0x42, 0x4D]),                    // BMP
    new Uint8Array([0x52, 0x49, 0x46, 0x46]),       // WEBP (RIFF...WEBP)
  ],
  pdf: [new Uint8Array([0x25, 0x50, 0x44, 0x46])],  // %PDF
  csv: [],  // no reliable magic bytes, handled differently
  json: [], // no reliable magic bytes, validated after parse
  zip: [
    new Uint8Array([0x50, 0x4B, 0x03, 0x04]),       // ZIP
    new Uint8Array([0x50, 0x4B, 0x05, 0x06]),       // ZIP empty archive
    new Uint8Array([0x50, 0x4B, 0x07, 0x08]),       // ZIP spanned
  ],
  unknown: [],
};

function detectType(buffer: Buffer): FileType {
  for (const [type, sigs] of Object.entries(MAGIC_BYTES)) {
    if (sigs.length === 0) continue;
    const matched = sigs.some((sig) =>
      buffer.subarray(0, sig.length).equals(sig)
    );
    if (matched) return type as FileType;
  }
  return 'unknown';
}

function createContentValidator(allowedTypes: FileType[]) {
  let inspected = false;

  return new Transform({
    transform(chunk, _encoding, callback) {
      if (!inspected) {
        const header = chunk.subarray(0, 16);
        const detected = detectType(header);
        if (!allowedTypes.includes(detected)) {
          callback(new Error(`Rejected file type: ${detected}`));
          return;
        }
        inspected = true;
      }
      callback(null, chunk);
    },
  });
}

This runs in the stream before the file reaches storage. A JPEG renamed to .zip is caught because the magic bytes say FF D8 FF not PK 03 04. By the time the transform finishes, the file has already been rejected without touching disk.

The tradeoff is that the first chunk must contain enough bytes. The standard is 16 bytes, which covers every common format. Files smaller than 16 bytes (a 5-byte text file) are not caught by magic-byte validation and need a separate path.

Stage 3: Size enforcement at the stream boundary

The busboy fileSize limit from Stage 1 catches oversized files at the multipart parser level. But if you use raw busboy or a custom parser, you need a stream-level size check too. A simple Transform that counts bytes and rejects past a threshold:

function createSizeLimiter(maxBytes: number) {
  let total = 0;
  return new Transform({
    transform(chunk, _encoding, callback) {
      total += chunk.length;
      if (total > maxBytes) {
        callback(new Error(`File exceeds ${maxBytes} byte limit`));
        return;
      }
      callback(null, chunk);
    },
  });
}

This looks redundant if you already set limits.fileSize in multer. It is not. A custom parser that does not use multer, or a direct busboy integration, does not enforce the limit unless you add it yourself. The sizeLimiter stream is insurance that works regardless of the parser.

Stage 4: Safe storage with sanitized filenames

The default multer behavior of dest: 'uploads/' with the original filename is broken in production for three reasons:

  1. Path traversal. A filename like ../../etc/passwd writes outside the uploads directory.
  2. Collision. Two users uploading resume.pdf overwrite each other’s files.
  3. No partitioning. Thousands of files in a single directory cause filesystem slowdowns (ext4 and XFS degrade past ~10,000 entries per directory).

Fix all three by generating a storage key that is derived from the content, not the client:

import { randomUUID } from 'node:crypto';
import path from 'node:path';

interface StorageKey {
  directory: string;
  filename: string;
  key: string; // full relative path
}

function generateStorageKey(originalName: string): StorageKey {
  const ext = path.extname(originalName).toLowerCase();
  const safeExt = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.csv', '.json', '.zip']
    .includes(ext) ? ext : '.bin';

  // Create a date-partitioned directory structure: uploads/2026/06/18/
  const now = new Date();
  const dir = `uploads/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${String(now.getUTCDate()).padStart(2, '0')}`;

  const uuid = randomUUID();
  const filename = `${uuid}${safeExt}`;
  const key = `${dir}/${filename}`;

  return { directory: dir, filename, key };
}

UUID filenames eliminate collisions. Date-partitioned directories keep each folder well under 10,000 files. The extension is validated against an allowlist; everything else becomes .bin. No path traversal is possible because the output path is entirely server-generated.

If you use S3, R2, or GCS, the same logic applies: the object key is all that matters. Use uploads/2026/06/18/<uuid>.pdf as the key and you get partitioning for free.

Stage 5: Stream-to-storage with no temp files

The production pattern streams the file directly to its final destination without writing to a local temp file first. For local disk, that means a WriteStream to the final path. For S3, that means the Upload class from the AWS SDK v3, which accepts a stream and handles multipart upload automatically.

Here is a complete upload handler that chains all the stages:

import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import multer from 'multer';

const ALLOWED_TYPES = ['image', 'pdf', 'csv', 'zip'] as const;

// No dest option -- we handle storage ourselves
const upload = multer({
  storage: multer.memoryStorage(), // keep it in memory *only* for streaming
  limits: {
    fileSize: 10 * 1024 * 1024, // 10 MB
    files: 1,
    fields: 5,
  },
  // fileFilter runs before storage; we use it for a quick extension sanity check
  fileFilter: (_req, file, cb) => {
    const allowedMimes = [
      'image/jpeg', 'image/png', 'image/gif', 'image/webp',
      'application/pdf',
      'text/csv', 'application/vnd.ms-excel',
      'application/zip', 'application/x-zip-compressed',
    ];
    if (allowedMimes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error(`Unsupported content-type: ${file.mimetype}`));
    }
  },
});

app.post('/api/upload', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file provided' });
  }

  const originalName = req.file.originalname;
  const storageKey = generateStorageKey(originalName);

  try {
    // Ensure the directory exists
    const fullDir = path.join(process.cwd(), storageKey.directory);
    if (!existsSync(fullDir)) {
      mkdirSync(fullDir, { recursive: true });
    }

    const fullPath = path.join(process.cwd(), storageKey.key);

    // Chain the stages: buffer -> content validator -> size limiter -> file
    const source = Readable.from(req.file.buffer);
    const validator = createContentValidator(ALLOWED_TYPES);
    const sizeLimiter = createSizeLimiter(10 * 1024 * 1024);
    const destination = createWriteStream(fullPath);

    await pipeline(source, validator, sizeLimiter, destination);

    res.status(201).json({
      key: storageKey.key,
      originalName,
      size: req.file.size,
    });
  } catch (err: any) {
    // Clean up partial file if pipeline failed mid-write
    const fullPath = path.join(process.cwd(), storageKey.key);
    try { await unlink(fullPath); } catch { /* file may not exist */ }

    res.status(400).json({
      error: err.message || 'Upload failed',
    });
  }
});

A few details worth calling out:

multer.memoryStorage() is intentional. You do not want multer writing to a temp directory that you then have to clean up. Memory storage gives you a Buffer. For files smaller than the default Node memory limit (roughly 1 GB on 64-bit), this is fine. For files over 100 MB, swap to a streaming parser like busboy directly, which gives you file parts as streams instead of buffers. The pipeline stays the same; only the source changes from Readable.from(buffer) to the part stream from busboy.

pipeline handles errors. If the validator rejects mid-stream, pipeline destroys the destination stream automatically. A partial file never reaches the final path intact. The catch block’s unlink is insurance for the edge case where the error fires after the first write but before the stream is destroyed.

No temp files means no cleanup cron job. A surprising number of production servers accumulate gigabytes of partially uploaded files in /tmp because the cleanup logic was never written. The streaming pipeline eliminates the problem.

Stage 6: Post-upload content scanning

The magic-byte check in Stage 2 catches mismatched file types. It does not catch a JPEG that contains a steganographic payload or a PDF that exploits a vulnerability in the rendering library. For those, you need a content scanner after the file is stored.

The practical approach is to integrate ClamAV via the clamscan npm package and run a scan after the pipeline succeeds:

import NodeClam from 'clamscan';

const clamscan = await new NodeClam().init({
  clamdscan: {
    socket: '/var/run/clamav/clamd.sock',
    timeout: 30000,
  },
});

async function scanFile(filePath: string): Promise<boolean> {
  try {
    const { isInfected } = await clamscan.isInfected(filePath);
    return isInfected;
  } catch {
    // clamd not running or unreachable -- decide your failure policy
    // Returning true (infected) is the safe default
    return true;
  }
}

// After pipeline succeeds:
const infected = await scanFile(fullPath);
if (infected) {
  await unlink(fullPath);
  return res.status(400).json({ error: 'File rejected by security scan' });
}

Run clamd as a sidecar in your container or as a separate service. The scan adds 100-500 ms per file, which is fine for upload endpoints that are already async from the client’s perspective. For high-throughput pipelines, defer the scan to a background worker and return a 202 Accepted with a scan status endpoint the client can poll.

What about S3 and cloud storage?

The same six-stage pipeline applies to cloud storage. The only change is the final destination: instead of a WriteStream, you use the AWS SDK’s Upload (which accepts a readable stream):

import { Upload } from '@aws-sdk/lib-storage';
import { S3Client } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: 'us-east-1' });

async function streamToS3(
  source: Readable,
  bucket: string,
  key: string,
): Promise<void> {
  const upload = new Upload({
    client: s3,
    params: {
      Bucket: bucket,
      Key: key,
      Body: source,
    },
    queueSize: 4,   // concurrent parts
    partSize: 5 * 1024 * 1024, // 5 MB
  });

  await upload.done();
}

The content validator and size limiter streams sit between the source and the S3 upload. If they reject, the S3 upload never starts. No partial objects, no cleanup needed.

For GCS, use @google-cloud/storage’s createWriteStream. For R2, the S3-compatible API means the same @aws-sdk/lib-storage code works with a different endpoint.

Common mistakes and how to catch them

Trusting the file extension. The client sends filename="report.pdf", and your validation checks path.extname(). The file is a ZIP bomb. Use magic bytes. The extension is for UX, not security.

Allowing symlinks in the upload directory. If your storage path is traversable by another process, a symlink attack can redirect a write to any file on the system. Write to a directory that is not world-writable. In containers, use a dedicated volume with noexec and nosuid mount flags.

Not setting a request body size limit. Multer’s fileSize limit applies per file. The total request body can still be larger if the request includes many small fields. Set the Express limit option on the body parser or use a reverse proxy (Nginx, Caddy) to cap the inbound body size at the edge.

Storing files on ephemeral disk without replication. Containers restart, pods reschedule, instances terminate. Files stored on local disk disappear. If you need durability, stream to S3, R2, or GCS from the start. The streaming pipeline is the same; only the target changes.

Forgetting to clean up on validation failure. If the magic-byte validator rejects after the stream has written 10 KB to S3, the S3 multipart upload leaves an incomplete object. The AWS SDK’s Upload class does not clean up automatically on error. Wrap the upload in a try-catch and delete the object if the pipeline fails.

try {
  await pipeline(source, validator, sizeLimiter, s3Upload);
} catch {
  await s3.deleteObject({ Bucket, Key }).catch(() => {});
  throw;
}

The six-stage checklist

Before you ship an endpoint that accepts files, run through this:

  • Protocol limits set on busboy or multer (fileSize, files, fields).
  • Content-type header checked as a hint (reject obviously wrong types fast).
  • Magic-byte validator in the stream (reject mismatched types before storage).
  • Size limiter in the stream (reject oversized files at the stream boundary, not after).
  • Safe storage key (UUID filename, date-partitioned directory, validated extension).
  • Post-upload scan (ClamAV or equivalent for files over a threshold).

Skip any one, and you have a gap. Most production breaches from file uploads exploit the skipped stages, not the ones you got right.

The practical takeaway

File uploads are the most dangerous feature an API can expose because they combine untrusted input, disk I/O, and cross-system data flow. The naive handler that ships in every “build a file upload API” tutorial is unsafe by default. The fix is not a single library. It is a pipeline of streams, each enforcing one invariant early, before the next stage sees the data.

Busboy limits catch the protocol-level abuse. Magic-byte validation catches the renamed executable. Size limiters catch the ZIP bomb before it decompresses. Safe storage keys prevent collision and traversal. Post-upload scanning catches the payloads that slip through.

A well-constructed upload pipeline rejects most bad input in the first 100 bytes of the stream, before the file touches your storage or consumes meaningful CPU. The rest of the pipeline is insurance for the cases the earlier stages miss. Build the pipeline once, reuse it across every endpoint, and audit it when you add a new file type.

Your product manager’s CSV files will arrive safely. The ZIP bombs will not.


A note from Yojji

Building secure data ingestion pipelines is the kind of unglamorous engineering that separates a hobby project from a production system. The six-stage upload pattern in this post mirrors the approach Yojji’s teams take on every engagement: validate early, fail fast, and never trust user-supplied data without verification at the protocol, content, and storage layers. Their senior engineers bring this production mindset to the Node.js applications they build for clients ranging from early-stage products to enterprise platforms.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. They offer full-cycle product development and dedicated team augmentation, specializing in the JavaScript ecosystem, AWS/Azure/GCP infrastructure, and microservices architectures.