Trusting Nothing from the Client

Cap the request body, check its type and shape, and stop a path or URL from the client reaching somewhere it should not.

Syntaxreq.on('data', (c) => { if ((size += c.length) > MAX_BODY) { res.writeHead(413).end(); req.destroy(); } });

Everything in a request is written by whoever sent it. The body, the headers, the query string, the path. A client can send 2 GB of JSON, a filename of ../../etc/passwd, or a URL pointing at your own internal network. Your handler decides what is acceptable; nothing else does.

Cap the body before you buffer it

The request body is the bytes after the headers. Node hands it to you in chunks as they arrive. If you collect chunks into an array with no limit, one large upload grows your process memory until it is killed. Count bytes as they arrive, and once past your limit respond 413 Payload Too Large and call req.destroy() to close the connection rather than reading the rest.

Check the type, then the shape

Reject a Content-Type you do not handle with 415 Unsupported Media Type. After JSON.parse you have an object of unknown shape: check each field's type and length, and copy out the fields you expect instead of passing the whole parsed body onward.

Paths and URLs

Path traversal is a filename crafted to escape the directory you meant, using .. segments or a leading slash. Resolve the path against your root, then confirm the result is still inside it. SSRF, server-side request forgery, is the same trick for outbound requests: the client gives you a URL, your server fetches it, and the URL points at 127.0.0.1 or a cloud metadata address that only your server can reach. Allow-list the scheme, resolve the hostname, and check the resulting IP address.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Stop a single large upload from exhausting the memory of a Node service.
  • Serve files by name from an uploads directory without letting a request read /etc/passwd.
  • Accept a user-supplied webhook URL without letting it reach internal services or cloud metadata.
  • Reject malformed or oversized JSON with 413 and 415 instead of a 500 from a thrown parse error.

More examples

A body limit that holds for chunked requests too

This is the whole pattern in one handler: reject the wrong type, reject an oversized declared length, then still count real bytes. A framework body parser does the same thing behind a limit option, so knowing the shape tells you what that option actually changes.

Example · javascript
const http = require('node:http');
const MAX_BODY = 64 * 1024;             // 64 KiB is plenty for a JSON body

const server = http.createServer((req, res) => {
  if (req.headers['content-type'] !== 'application/json') {
    res.writeHead(415).end();           // type we do not handle
    return;
  }

  // Fast path: the client told us up front that it is too big.
  const declared = Number(req.headers['content-length']);
  if (Number.isFinite(declared) && declared > MAX_BODY) {
    res.writeHead(413).end();
    req.destroy();
    return;
  }

  let size = 0;
  const chunks = [];
  req.on('data', (chunk) => {
    size += chunk.length;               // chunk is a Buffer: length is bytes
    if (size > MAX_BODY) {
      res.writeHead(413).end();
      req.destroy();                    // stop reading; drop the connection
      return;
    }
    chunks.push(chunk);
  });

  req.on('end', () => {
    if (req.destroyed) return;          // already answered with 413
    const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ bytes: size }));
  });
});

server.listen(3000, '127.0.0.1');

An SSRF guard for a client-supplied URL

Any feature that fetches a URL the user typed - a webhook test, an avatar import, a link preview - can be aimed at your internal network. Validating the hostname string is not enough, because DNS decides where it goes. Resolve first, check the address, and re-check after every redirect.

Example · javascript
const dns = require('node:dns').promises;
const net = require('node:net');

// A BlockList (node:net, added v15.0.0) holds IP ranges and answers
// whether an address falls in one of them.
const blocked = new net.BlockList();
blocked.addSubnet('10.0.0.0', 8);
blocked.addSubnet('172.16.0.0', 12);
blocked.addSubnet('192.168.0.0', 16);
blocked.addSubnet('127.0.0.0', 8);      // your own machine
blocked.addSubnet('169.254.0.0', 16);   // cloud metadata lives at 169.254.169.254
// Node 24.21.0 adds net.BlockList.PRIVATE_RANGES and addCIDRs() for this list.

async function checkedUrl(input) {
  const url = new URL(input);           // throws on a malformed input
  if (url.protocol !== 'https:') throw new Error('only https is allowed');

  // Check the address the hostname resolves to, not the hostname itself:
  // an attacker can point their own domain at 127.0.0.1.
  const { address, family } = await dns.lookup(url.hostname);
  if (blocked.check(address, family === 6 ? 'ipv6' : 'ipv4')) {
    throw new Error('target resolves to a private address');
  }
  return { url, address };
}

// Fetch with redirects off, then run any redirect target through the same
// check: a public URL is allowed to redirect to a private one.
// const res = await fetch(url, { redirect: 'manual' });

Copy out the fields you expect

Checking fields in place and then passing the original object on is how an extra key such as isAdmin or role:'owner' reaches code that trusts it. Returning a fresh object makes the accepted shape explicit. Use this on every write endpoint, including ones only your own front end calls.

Example · javascript
// The parsed body is an object of unknown shape. Validate each field and
// build a new object from scratch, so unexpected keys cannot travel on
// into a database update or an object merge.
function readProfileInput(body) {
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
    throw new Error('body must be an object');
  }
  const errors = [];

  const name = body.name;
  if (typeof name !== 'string' || name.length < 1 || name.length > 80) {
    errors.push('name must be a string of 1 to 80 characters');
  }

  // A number arriving as "42" from a form is a string. Convert explicitly.
  const age = Number(body.age);
  if (!Number.isInteger(age) || age < 13 || age > 120) {
    errors.push('age must be a whole number between 13 and 120');
  }

  const role = ['reader', 'author'].includes(body.role) ? body.role : 'reader';

  if (errors.length) throw Object.assign(new Error('invalid input'), { errors });
  return { name, age, role };           // only these three fields leave here
}

Discussion

  • Be the first to comment on this lesson.