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
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 with403 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();
Register the body parsers first, so the WAF sees the parsed payload:
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
Then add the WAF with every preset at the default level:
app.use(expressWaf({ presets: ['default'], level: 'balanced' }));
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);
Fastify
Fastify ships as a plugin in mini-waf/fastify:
import Fastify from 'fastify';
import { fastifyWaf } from 'mini-waf/fastify';
const app = Fastify();
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' },
});
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 });
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';
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',
});
This route matches everything. {*path} is the Nest 11 wildcard; on Nest 10,
use '*':
const everyRoute = { path: '{*path}', method: RequestMethod.ALL };
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);
}
}
main.ts needs no changes:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
NestFactory.create(AppModule).then((app) => app.listen(3000));
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' });
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,
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 ?? '',
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;
},
});
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();
});
Your routes, or @koa/router, go after it:
app.use((ctx) => {
ctx.body = { q: ctx.query.q ?? null };
});
app.listen(3000);
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--"
A path traversal attempt trips preset-path-traversal:
curl -i "localhost:3000/search?q=../../etc/passwd"
A scanner's user agent trips preset-scanners-ua:
curl -i -A "sqlmap/1.8" localhost:3000/search
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>"}'
Normal traffic still goes through:
curl -i "localhost:3000/search?q=shoes"
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' },
};
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 } },
],
},
};
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],
});
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)