DEV Community

Cover image for Add a Web Application Firewall to Your Node.js API in Five Minutes
Muryllo Pimenta
Muryllo Pimenta

Posted on

Add a Web Application Firewall to Your Node.js API in Five Minutes

Most Node.js APIs go to production with no request filtering at all. Input
validation catches malformed data, but it is not built to spot a SQL
injection hidden in a search box, a path traversal trying to read system
files, or an automated scanner probing every route. That is the job of a Web
Application Firewall (WAF).

When I went looking for one for my own Node.js projects, I was surprised by
how little there was. Good security tooling is hard to find in this corner of
the ecosystem: most of the WAF packages I found on npm were abandoned, stuck
on old framework versions, or had not seen a commit in years. Heavyweight
options like ModSecurity exist, but they live in front of your app, not inside
it, and they were not designed with Node.js developers in mind.

So I decided to build one myself, shaped around what the Node.js community
actually needs. mini-waf is a small
WAF that runs inside your app as a middleware. It has zero runtime
dependencies, ships typed rule presets derived from the OWASP Core Rule Set,
and works with Express, Fastify, NestJS and, through a custom adapter, any
other framework.

npm install mini-waf
Enter fullscreen mode Exit fullscreen mode

How it works

Every request is turned into a framework-agnostic context and evaluated
against an ordered list of rules. Each rule has a condition and an action:

  • block: stop the request with 403 Forbidden;
  • allow: skip all remaining rules;
  • log: record the match and keep going.

You rarely write rules from scratch. Instead you pick presets (sqli,
xss, rce, rfi, path-traversal, scanners, protocol, or default
for all of them) and a protection level: low, balanced (the
default), high or paranoid. Higher levels turn on more rules at the cost
of more false positives.

One rule matters more than any other: the body parser must run before the
WAF
. Otherwise the body is empty when the WAF sees it and payload rules
never fire.

Express

Start with a plain Express app. The WAF lives in the mini-waf/express
entrypoint:

import express from 'express';
import { expressWaf } from 'mini-waf/express';

const app = express();
Enter fullscreen mode Exit fullscreen mode

Register the body parsers first, so the WAF sees the parsed payload:

app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
Enter fullscreen mode Exit fullscreen mode

Then add the WAF with every preset at the default level:

app.use(expressWaf({ presets: ['default'], level: 'balanced' }));
Enter fullscreen mode Exit fullscreen mode

Routes come last. Anything that reaches them has already been checked:

app.get('/search', (req, res) => res.json({ q: req.query.q ?? null }));
app.post('/comments', (req, res) => res.status(201).json(req.body));
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Fastify

Fastify ships as a plugin in mini-waf/fastify:

import Fastify from 'fastify';
import { fastifyWaf } from 'mini-waf/fastify';

const app = Fastify();
Enter fullscreen mode Exit fullscreen mode

Register it before your routes. Fastify parses JSON before the preHandler
hook where mini-waf runs, so there is nothing to order by hand:

await app.register(fastifyWaf, {
  config: { presets: ['default'], level: 'balanced' },
});
Enter fullscreen mode Exit fullscreen mode

Your routes stay exactly as they were:

app.get('/search', async (request) => ({ q: request.query.q ?? null }));
app.post('/comments', async (request) => request.body);
await app.listen({ port: 3000 });
Enter fullscreen mode Exit fullscreen mode

NestJS

The NestJS integration is a module plus a middleware, both in
mini-waf/nestjs:

import {
  Module,
  NestModule,
  MiddlewareConsumer,
  RequestMethod,
} from '@nestjs/common';
import { MiniWafModule, MiniWafMiddleware } from 'mini-waf/nestjs';
import { AppController } from './app.controller';
Enter fullscreen mode Exit fullscreen mode

forRoot holds the config. platform picks the adapter for Nest on
Express or on Fastify; 'auto' detects it:

const wafModule = MiniWafModule.forRoot({
  config: { presets: ['default'], level: 'balanced' },
  platform: 'auto',
});
Enter fullscreen mode Exit fullscreen mode

This route matches everything. {*path} is the Nest 11 wildcard; on Nest 10,
use '*':

const everyRoute = { path: '{*path}', method: RequestMethod.ALL };
Enter fullscreen mode Exit fullscreen mode

Import the module and apply the middleware. Nest's body parser runs before
middleware, so bodies are inspected:

@Module({ imports: [wafModule], controllers: [AppController] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(MiniWafMiddleware).forRoutes(everyRoute);
  }
}
Enter fullscreen mode Exit fullscreen mode

main.ts needs no changes:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

NestFactory.create(AppModule).then((app) => app.listen(3000));
Enter fullscreen mode Exit fullscreen mode

Koa (custom adapter)

Koa has no built-in integration, which makes it a good tour of the core API.
Create the engine once, at startup:

import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import { createAdapter, createMiniWaf } from 'mini-waf';

const waf = createMiniWaf({ presets: ['default'], level: 'balanced' });
Enter fullscreen mode Exit fullscreen mode

An adapter tells mini-waf how to read a request. The first getters cover
the request line:

const koaAdapter = createAdapter<Koa.Context, Koa.Context>({
  name: 'koa',
  getMethod: (ctx) => ctx.method,
  getUrl: (ctx) => ctx.url,
  getPath: (ctx) => ctx.path,
Enter fullscreen mode Exit fullscreen mode

The next ones cover the client IP, headers, query and body. Every rule reads
through these, so a wrong getIp breaks all IP rules and rate limits:

  getIp: (ctx) => ctx.ip,
  getHeader: (ctx, name) => ctx.get(name) || undefined,
  getHeaders: (ctx) => ctx.headers,
  getQuery: (ctx) => ctx.query,
  getRawBody: (ctx) => ctx.request.body ?? '',
Enter fullscreen mode Exit fullscreen mode

The last two tell it how to write a response and how to reject a request:

  setResponseHeader: (ctx, name, value) => ctx.set(name, String(value)),
  drop: (ctx, _res, status, body) => {
    ctx.status = status;
    ctx.body = body;
  },
});
Enter fullscreen mode Exit fullscreen mode

Wire it into a middleware. waf.protect returns the decision, and only
allowed requests continue down the chain:

const app = new Koa();
app.use(bodyParser({ enableTypes: ['json', 'form'] }));
app.use(async (ctx, next) => {
  const result = await waf.protect(koaAdapter, ctx, ctx);
  if (result.decision === 'allow') await next();
});
Enter fullscreen mode Exit fullscreen mode

Your routes, or @koa/router, go after it:

app.use((ctx) => {
  ctx.body = { q: ctx.query.q ?? null };
});
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The same pattern works for raw node:http or any other framework that
exposes a request and a response.

Complete, runnable versions of all four servers are in the repository's
integration/
folder.

See it block

Start any of the servers above and send it some attacks. Each one comes
back as 403 Forbidden.

A classic SQL injection in the query string trips preset-sqli-classic-query:

curl -i "localhost:3000/search?q=%27%20OR%201%3D1--"
Enter fullscreen mode Exit fullscreen mode

A path traversal attempt trips preset-path-traversal:

curl -i "localhost:3000/search?q=../../etc/passwd"
Enter fullscreen mode Exit fullscreen mode

A scanner's user agent trips preset-scanners-ua:

curl -i -A "sqlmap/1.8" localhost:3000/search
Enter fullscreen mode Exit fullscreen mode

A script tag in a JSON body trips preset-xss-body:

curl -i -X POST localhost:3000/comments -H 'content-type: application/json' \
  -d '{"text":"<script>alert(1)</script>"}'
Enter fullscreen mode Exit fullscreen mode

Normal traffic still goes through:

curl -i "localhost:3000/search?q=shoes"
Enter fullscreen mode Exit fullscreen mode

Tune it for your app

Custom rules use the same DSL as the presets. This one lets health checks
skip every other rule; priority: 1 makes it run first:

import type { WafRule } from 'mini-waf';

const allowHealth: WafRule = {
  id: 'allow-health',
  priority: 1,
  action: 'allow',
  when: { field: 'path', equals: '/health' },
};
Enter fullscreen mode Exit fullscreen mode

This one blocks an IP after 100 login attempts in a minute:

const loginLimit: WafRule = {
  id: 'login-rate-limit',
  action: 'block',
  when: {
    all: [
      { field: 'path', equals: '/auth/login' },
      { field: 'ip', rateLimit: { max: 100, windowMs: 60_000 } },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

Every integration takes the same config. disabledRuleIds silences a rule
that misfires on your traffic, and logging prints blocks to the console
(it is off by default, so there is no I/O in production):

expressWaf({
  presets: ['default'],
  level: 'balanced',
  logging: true,
  disabledRuleIds: ['preset-scanners-ua'],
  rules: [allowHealth, loginLimit],
});
Enter fullscreen mode Exit fullscreen mode

Rule ids are stable, so turning off a single noisy rule is safer than dropping
a whole preset. Custom rules can match on ip, method, path, url,
body, files, or specific keys like query.id, headers.user-agent and
cookies.session, using equals, includes, matches (regex) or
rateLimit, combined with all, anyOf and not.

A sensible rollout is to start at balanced with logging: true, watch for
false positives for a few days, disable the rule ids that misfire, then
consider high for sensitive endpoints.

Where it fits

mini-waf is a layer inside your process, not a replacement for a network
firewall, DDoS protection at the edge, parameterized queries or output
escaping. It catches the large volume of automated attacks that reach every
public API, it is cheap enough to run on every request, and you keep it in
the same repository as the code it protects.

Top comments (0)