Security & Production Hardening

A senior's checklist: dependency risk, input limits, secrets, permissions, and safe defaults.

Most Node breaches are boring: an outdated dependency, a leaked token, an unbounded request body. Harden the boring stuff and you have closed most of the door.

Dependencies are your biggest attack surface

  • npm audit in CI, and commit your lockfile so builds are reproducible.
  • Install with npm ci in CI/production — it installs exactly the lockfile, never mutates it.
  • Use npm install --ignore-scripts or a policy where possible; postinstall scripts are a common supply-chain vector.
  • Fewer dependencies is a security feature. Vet before you add.

Runtime hardening

  • Limit input — cap request body size, JSON depth, and array lengths. An unbounded body is a trivial DoS.
  • Never build shell strings — use execFile/spawn with an args array, never exec with interpolated input (command injection).
  • Validate at the boundary — treat every external input as hostile; validate with a schema before it reaches business logic.
  • Run as non-root, read-only filesystem where possible, drop container capabilities.
// SAFE: arguments are passed as data, not parsed by a shell.
const { execFile } = require('node:child_process');
execFile('git', ['log', '--oneline', userBranch], (err, out) => { /* ... */ });

// UNSAFE: userBranch could be '; rm -rf /'
// exec(`git log ${userBranch}`)

The Permission Model

Node's --permission flag (stabilizing in the 24-era) lets you deny filesystem, child-process, and worker access by default and allow-list only what the app needs — real defense in depth for running third-party code.

Example

Example · javascript
// A hardened HTTP entrypoint: body-size cap, timeouts, safe subprocess use,
// schema-validated input, and least-privilege headers. Framework-agnostic.
const http = require('node:http');
const { execFile } = require('node:child_process');
const { z } = require('zod');

const MAX_BODY = 64 * 1024; // 64 KB — reject anything larger, unread.
const Query = z.object({ repo: z.string().regex(/^[\w.-]{1,64}$/) });

const server = http.createServer((req, res) => {
  let size = 0;
  const chunks = [];
  req.on('data', (c) => {
    size += c.length;
    if (size > MAX_BODY) { res.writeHead(413).end('Payload Too Large'); req.destroy(); return; }
    chunks.push(c);
  });
  req.on('end', () => {
    // Validate BEFORE using input; reject on any mismatch.
    const parsed = Query.safeParse(JSON.parse(Buffer.concat(chunks).toString() || '{}'));
    if (!parsed.success) { res.writeHead(400).end('Bad Request'); return; }

    // execFile with an args array — no shell, no injection.
    execFile('git', ['ls-remote', parsed.data.repo], { timeout: 5000 }, (err, stdout) => {
      if (err) { res.writeHead(502).end('Upstream error'); return; }
      res.writeHead(200, {
        'Content-Type': 'application/json',
        'X-Content-Type-Options': 'nosniff', // don't let browsers sniff types
      });
      res.end(JSON.stringify({ lines: stdout.split('\n').length }));
    });
  });
});

// Timeouts stop slow-loris style resource exhaustion.
server.requestTimeout = 10_000;
server.headersTimeout = 5_000;
server.listen(3000);

When to use it

  • A fintech API enables `--experimental-permission` in Node 20 to restrict the worker process to reading only a specific data directory, containing a path-traversal exploit.
  • A SaaS platform sets `Content-Security-Policy`, `Strict-Transport-Security`, and `X-Frame-Options` headers via `helmet` to mitigate common web attack vectors.
  • A DevSecOps pipeline runs `npm audit --audit-level=high` and fails the build if any high-severity CVE is found in the dependency tree.

More examples

Harden HTTP headers with helmet

Configures `helmet` with a strict Content Security Policy that allows scripts only from the same origin.

Example · js
const express = require('express');
const helmet  = require('helmet');

const app = express();
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc:  ["'self'"],
    },
  },
}));
app.listen(3000);

Enable Node permission model

Uses Node 20's experimental permission model to sandbox the process to specific filesystem paths.

Example · bash
# Node 20+ experimental permission model
# Restrict to reading files only in ./data
node --experimental-permission \
     --allow-fs-read=$(pwd)/data \
     --allow-fs-write=$(pwd)/output \
     server.js
# Any read outside ./data throws ERR_ACCESS_DENIED

Sanitize and validate user input

Validates request body shape with `zod` and sanitizes HTML content with `DOMPurify` before persisting it.

Example · js
const { z } = require('zod');
const DOMPurify = require('isomorphic-dompurify');

const BodySchema = z.object({
  title:   z.string().max(200),
  content: z.string().max(10_000),
});

app.post('/posts', (req, res) => {
  const { title, content } = BodySchema.parse(req.body);
  const safeContent = DOMPurify.sanitize(content);
  // save safeContent to DB
  res.status(201).json({ ok: true });
});

Discussion

  • Be the first to comment on this lesson.