mini-waf
v1.2.2
Published
Minimalistic Web Application Firewall for Node.js — declarative rules, immutable engine, Express/Fastify/NestJS adapters.
Downloads
1,410
Maintainers
Readme
Mini WAF
Minimal Web Application Firewall for Node.js (v1). Plug it in as middleware/plugin and evaluate each request with typed presets and declarative rules.
Supports Express, Fastify, and NestJS (optional peers — install only the framework you use).
Ships dual CommonJS + ESM with per-condition types, so require, import, and every moduleResolution mode (node10, node16, nodenext, bundler) resolve — subpaths included. Tree-shakeable (sideEffects: false), zero runtime dependencies.
Documentation: https://mini-waf.vercel.app/
| | | |---|---| | Get running | Quick start · Express · Fastify · NestJS · Custom adapters | | Understand it | Core concepts · Conditions & matchers · Protection levels | | Write rules | Presets · Custom rules · JSON rules | | Operate it | Logging · Performance · Benchmarking · Security notes |
Install
npm install mini-wafPeers per framework:
# Express
npm install mini-waf express
# Fastify
npm install mini-waf fastify
# NestJS
npm install mini-waf @nestjs/common @nestjs/core| Framework | Import |
|-----------|--------|
| Express | mini-waf/express |
| Fastify | mini-waf/fastify |
| NestJS | mini-waf/nestjs |
| Core / custom adapter | mini-waf |
Order matters: body parser (if any) → WAF → routes. Without a parser first, body may be empty and payload rules will not fire.
Quick start
Express
import express from 'express';
import { expressWaf } from 'mini-waf/express';
const app = express();
// 1) parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 2) WAF
app.use(
expressWaf({
presets: ['default'],
level: 'balanced',
}),
);
// 3) routes
app.get('/health', (_req, res) => res.send('ok'));
app.listen(3000);Optional secure headers: app.use(expressSecurityPolicy()) from mini-waf/express.
Fastify
import Fastify from 'fastify';
import { fastifyWaf } from 'mini-waf/fastify';
const app = Fastify();
// Fastify has a built-in JSON parser; register WAF before routes
await app.register(fastifyWaf, {
config: {
presets: ['default'],
level: 'balanced',
},
});
app.get('/health', async () => ({ ok: true }));
await app.listen({ port: 3000 });Without the plugin: fastifyPreHandler(config) as a manual preHandler hook.
Optional headers: app.addHook('onRequest', fastifySecurityPolicy()).
NestJS
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { MiniWafModule, MiniWafMiddleware } from 'mini-waf/nestjs';
@Module({
imports: [
MiniWafModule.forRoot({
config: {
presets: ['default'],
level: 'balanced',
},
platform: 'auto', // 'express' | 'fastify' | 'auto'
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Apply early (all routes). Nest/Express body parser already runs before this.
consumer.apply(MiniWafMiddleware).forRoutes('*');
}
}Functional factory (no module): nestMiddleware({ presets: ['default'], level: 'balanced' }).
Custom adapter
For frameworks without a built-in integration, use createAdapter + createMiniWaf:
import { createAdapter, createMiniWaf } from 'mini-waf';
interface KoaCtx {
method: string;
url: string;
ip: string;
headers: Record<string, string>;
query: Record<string, string>;
request: { rawBody?: string };
status: number;
body: string;
get(name: string): string | undefined;
set(name: string, value: string): void;
}
const koaAdapter = createAdapter<KoaCtx, KoaCtx>({
name: 'koa',
getMethod: (ctx) => ctx.method,
getUrl: (ctx) => ctx.url,
getIp: (ctx) => ctx.ip,
getHeader: (ctx, name) => ctx.get(name),
getHeaders: (ctx) => ctx.headers,
getQuery: (ctx) => ctx.query,
getRawBody: (ctx) => ctx.request.rawBody ?? '',
setResponseHeader: (ctx, name, value) => ctx.set(name, String(value)),
drop: (ctx, _res, status, body) => {
ctx.status = status;
ctx.body = body;
},
});
const waf = createMiniWaf({ presets: ['default'], level: 'balanced' });
// in middleware:
const result = await waf.protect(koaAdapter, ctx, ctx);
if (result.decision === 'allow') await next();Configuration
Everything goes through WafConfig in expressWaf(config), fastifyWaf (config or settings), MiniWafModule.forRoot({ config }), or createMiniWaf(config).
{
level?: 'low' | 'balanced' | 'high' | 'paranoid'; // default: 'balanced'
presets?: WafPresetName[];
rules?: WafRule[];
enabledRuleIds?: string[]; // optional allowlist (after merge + level)
disabledRuleIds?: string[]; // drop by id (after allowlist)
blockStatusCode?: number; // default: 403
blockBody?: string; // default: 'Forbidden'
logging?: false | true | { level?: 'error' | 'info' | 'debug'; sink?: WafLogger };
// logging default: false (off — no I/O)
// Performance (all optional)
maxFieldLength?: number; // truncate scanned field values; default 8192 (0 = unlimited)
ruleYieldEvery?: number; // yield to event loop every N rules; default 32 (0 = off)
maxRateLimitKeys?: number; // LRU cap on distinct rate-limit keys; default 10000
decisionCache?: { max?: number; ttlMs?: number }; // short-TTL decision LRU; off by default
decode?: { base64?: boolean; url?: boolean; comments?: boolean }; // decode whole-value Base64 (+ JSON body values), percent-encoding, and strip inline SQL comments, then rescan; auto-on at high+
}Performance knobs
| Option | Default | Purpose |
|--------|---------|---------|
| maxFieldLength | 8192 | Truncate each scanned field value (body, query, headers, …) before matching / regex. Limits regex cost on huge payloads without rejecting the HTTP body itself. Set 0 for unlimited (not recommended in production). |
| ruleYieldEvery | 32 | After every N rules, handle awaits setImmediate so large rule packs do not starve the event loop. Packs with fewer than N rules skip yielding (fully synchronous scan, still returns a Promise). Set 0 to always disable yielding. Safe together with rateLimit rules: counters live in a shared in-place store (no snapshot/replace race). |
| maxRateLimitKeys | 10000 | Cap on distinct rate-limit keys (usually per-IP buckets). Cold keys are evicted LRU-style when the cap is exceeded; idle keys are also pruned opportunistically. Bounds memory/CPU under IP floods even when preset-dos-rate-limit is active. |
| decisionCache | omitted (off) | Tiny LRU of allow/block decisions keyed by method + path + IP + query + UA + body hash. Automatically disabled when any active rule uses rateLimit so DoS counters still advance. Use only for mostly-static pattern rules; keep max / ttlMs small (defaults: 256 / 1000ms). |
| decode | auto-on at high+ | Three normalizers that rescan a decoded/de-obfuscated value with the existing rules: base64 decodes a whole-value Base64 blob (padded or unpadded) — a query/cookie value, or a single JSON body value like {"q":"<base64>"}; url percent-decodes a value carrying a %XX escape, reaching payloads sent percent-encoded on surfaces the framework does not decode (URL path, multipart, raw bodies); comments strip inline SQL comments used as token separators (SELECT/**/value/**/FROM, the space2comment tamper), preserving versioned /*!…*/. A new false-positive axis, so all are off at low/balanced and add zero cost there; set { base64: false, url: false, comments: false } to opt out at high+. Only Base64 values that survive a shape check and decode to mostly-printable text are rescanned, so tokens / UUIDs / image blobs are skipped. |
Normalized client IPs are also memoized in a process-local LRU (max 2048) — the same idea as geo/IP caches in lightweight WAF tutorials, without an external lru-cache dependency.
After the first matching block, later pure block rules (no rateLimit in their condition tree) are skipped. allow, log, and any rule with rate-limit side effects still run in order.
Field bags vs specific fields: Prefer query.id / headers.user-agent over bag fields (query, headers, cookies) when you know the target. Bags OR across every value and amplify matcher cost (especially regex and includes). Static includes needles are lowercased once at rule load; haystacks are memoized per request.
Regex / CRS-like patterns: Node has no sync RegExp timeout and no built-in RE2. Catastrophic backtracking on long strings is mitigated by maxFieldLength truncation before matches. Avoid nested quantifiers on attacker-controlled input ((a+)+, overlapping alternations on huge bodies). For hard guarantees, run matching in a worker with a wall-clock budget or use a linear-time engine outside this library.
Levels (level)
Each rule (preset or custom) may declare minLevel. It only applies when the configured level is greater than or equal to that minimum:
low < balanced < high < paranoid
Custom rules without minLevel are treated as low (active at any level).
| Level | Rules (default) | Includes | Typical use | ≈ CRS PL |
|-------|-------------------|----------|-------------|----------|
| low | 19 | Obvious scanners (UA), classic SQLi + DBMS primitives, plain & encoded traversal / LFI, stream-wrapper RFI, PHP RCE, shell RCE, JNDI/Log4Shell, reverse shells, fetch-and-exec, Windows LOLBins, SSRF metadata | APIs sensitive to false positives | PL1 (core) |
| balanced (default) | 51 | low + XSS (incl. encoded tags, data: URIs, attribute vectors, path), SQLi tautologies & SELECT … FROM, NoSQL operators (quoted & unquoted), uploads & extension bypass, remote-URL RFI, SSTI, FreeMarker, Node/lang exec, deserialization (binary & YAML), null-byte, DoS rate-limit, protocol splitting/smuggling, encoded & double-encoded CRLF | General production | PL1–PL2 |
| high | 81 | balanced + SSI, hex flood, prototype pollution, advanced/blind/boolean-equality/compact-subquery/JSON SQLi, NoSQL driver API & $where timing DoS, LDAP filter & matching-rule, XXE, mail command / IMAP / QUIT injection, UNC paths, XSS JS primitives, indirect & breakout sink calls, CL+TE, shell $() and parameter-expansion tricks, session ID in URL | Under attack / broader coverage | PL2 |
| paranoid | 94 | high + broad UAs, generic HTML tags, empty UA, shebang, oversized headers, internal-host SSRF, GraphQL introspection, ASP concat obfuscation, NoSQL $where time-bomb, MSSQL DECLARE, cmd set /a, CRLF-less mail verbs | Max coverage; more FPs | PL3–PL4 |
expressWaf({
level: 'high',
presets: ['default'],
rules: [
{
id: 'strict-probe',
minLevel: 'high',
action: 'block',
when: { field: 'query.debug', equals: '1' },
reason: 'Debug flag blocked at high+',
},
],
});Presets
{ presets: ['default'] }
// same as: sqli + xss + scanners + path-traversal + rfi + rce + protocolIndividual presets: 'sqli' | 'xss' | 'scanners' | 'path-traversal' | 'rfi' | 'rce' | 'protocol' | 'default'.
| Preset | Rules | Focus (CRS-derived) |
|--------|-------|---------------------|
| sqli | 22 | REQUEST-942 — classic, tautology, DBMS primitives, blind, boolean equality, compact subquery, JSON functions, MSSQL DECLARE, NoSQL operators / driver API / $where DoS |
| xss | 14 | REQUEST-941 (+ SSI) — tags, encoded tags, data: URIs, attribute vectors, JS primitives, indirect & breakout sink calls |
| scanners | 12 | REQUEST-913 / 912 — scanner UAs, LDAP filter & matching-rule, GraphQL introspection, null-byte, pollution, hex flood, DoS rate |
| path-traversal | 5 | REQUEST-930 — plain and encoded traversal, UNC / admin-share paths, LFI |
| rfi | 7 | REQUEST-931 / 933 — stream wrappers, remote script include, PHP RCE, XXE, uploads |
| rce | 19 | REQUEST-932 / 934 — shell (incl. set /a), JNDI/Log4Shell, reverse shells, LOLBins, SSTI, FreeMarker, deserialization |
| protocol | 15 | REQUEST-920 / 921 / 943 — splitting, smuggling, CRLF, mail / IMAP / QUIT injection, header injection, CL+TE, Host IP, session |
The per-rule breakdown, with what each id catches, is in the presets guide.
Each preset rule already has a minLevel; your config level decides which ones run.
Only what you need:
expressWaf({
level: 'balanced',
presets: ['sqli', 'xss', 'scanners'],
});Enable / disable rules by id
After presets and custom rules are merged, the engine builds the active list in this order:
- Resolve presets + custom
rules - Filter by protection
level(minLevel) - Apply
enabledRuleIds(if present and non-empty — allowlist) - Apply
disabledRuleIds - Drop
enabled: falseand sort bypriority
expressWaf({
presets: ['default'],
level: 'balanced',
// Turn off one noisy preset rule:
disabledRuleIds: ['preset-scanners-ua'],
// Or keep only a short allowlist (presets + custom):
// enabledRuleIds: ['preset-sqli-classic-query', 'allow-health'],
rules: [
{
id: 'allow-health',
priority: 1,
action: 'allow',
when: { field: 'path', equals: '/health' },
},
],
});Empty enabledRuleIds / disabledRuleIds are no-ops.
Logging (off by default)
Logging is disabled by default so production paths do no console I/O and no formatting. When off, the engine never calls the logger.
| Setting | Behavior |
|---------|----------|
| omitted / false | Silent (default) |
| true | Plain console at level info (blocks + audit) |
| { level, sink? } | error = blocks only; info = blocks + audit; debug = + connections. Optional injectable WafLogger |
No color libraries — console output is plain text. File sinks are not built-in; pass a custom sink if you need them.
import type { WafLogger } from 'mini-waf';
const sink: WafLogger = {
blocked: (ctx, rule) => myMetrics.inc('waf_block', { rule: rule.id }),
audit: () => undefined,
connection: () => undefined,
};
expressWaf({
presets: ['default'],
logging: true,
// or: logging: { level: 'debug', sink },
});You can also pass { logger } in the second argument to createMiniWaf / expressWaf — it is used only when logging is enabled.
Custom rules
import type { WafRule } from 'mini-waf';
const rules: WafRule[] = [
{
id: 'block-sqli-query',
when: { field: 'query.id', matches: /('|OR\s+1=1)/i },
action: 'block',
reason: 'Possible SQL injection',
},
{
id: 'rate-limit-ip',
when: { field: 'ip', rateLimit: { max: 100, windowMs: 60_000 } },
action: 'block',
reason: 'Too many requests',
},
{
id: 'allow-health',
priority: 1, // lower = first; allow ends evaluation
when: { field: 'path', equals: '/health' },
action: 'allow',
},
];Fields (when.field)
| Field | Description |
|-------|-------------|
| ip, method, path, url, body, files | simple values |
| query, headers, cookies | all values (OR) — prefer dotted paths when possible |
| query.*, headers.*, cookies.* | specific field (cheaper than bags) |
Matchers
matches:string|RegExp|readonly string[](runs on truncated field values whenmaxFieldLength> 0)equals: exact equalityincludes: case-insensitive substring (needle lowercased at rule load)requires: literal prefilter — see belowrateLimit: { max, windowMs, keyPrefix? }- Compounds:
{ all: [...] },{ anyOf: [...] },{ not: ... }
requires — the literal prefilter
A value is only handed to matches when it contains one of these substrings
(case-insensitive). An indexOf scan is far cheaper than a regex pass over a
large body, and the lowercased view of each field is computed once per request
and shared by every rule, so this is how the presets keep a 94-rule pack cheap:
{
id: 'block-jndi',
action: 'block',
reason: 'Log4Shell probe',
when: {
field: 'body',
matches: /\$\{\s*(?:jndi|ctx|env|sys)\s*:/i,
requires: ['${'], // every payload this regex can match contains "${"
},
}The list must be complete. If a payload the pattern would match contains none of the literals, the rule silently misses it. Leave
requiresout when you are not sure, or when the pattern has no fixed literal (/^\d+$/).
It works in JSON rules too: "requires": ["${"].
Actions
allow— allow and stop evaluation (whitelist)block— block (blockStatusCode/blockBody, default 403 +Forbidden)log— collect for audit (emitted only when logging is on atinfo+)
JSON / serializable rules
RegExp and function predicates are not JSON-friendly. Use the serializable DSL (JsonWafRule) and compile with parseRulesFromJson / loadRules, then pass the result as rules:
import { parseRulesFromJson, createMiniWaf } from 'mini-waf';
import { readFileSync } from 'node:fs';
const rules = parseRulesFromJson(readFileSync('./rules.json', 'utf8'));
const waf = createMiniWaf({ presets: ['default'], rules });matches in JSON:
| Shape | Meaning |
|-------|---------|
| "exact" | exact string equality (same as live WafRule) |
| ["a", "b"] | OR list of exact strings |
| { "pattern": "...", "flags": "i" } | compiled to RegExp |
Top-level input may be a rule array or { "rules": [ ... ] }. Invalid shapes throw RuleParseError with a JSON path.
See examples/rules.example.json. The DSL is inspired by practical CRS categories / field targeting (e.g. SQLi ≈ REQUEST-942 on query/body) — it is not a ModSecurity / full OWASP CRS parser.
Real case: admin path + User-Agent
import express from 'express';
import { expressWaf } from 'mini-waf/express';
const app = express();
app.use(express.json());
app.use(
expressWaf({
presets: ['default'],
level: 'balanced',
rules: [
{
id: 'block-admin-surface',
action: 'block',
reason: 'Admin path blocked at edge',
when: {
anyOf: [
{ field: 'path', equals: '/admin' },
{ field: 'path', matches: /^\/admin\// },
],
},
},
{
id: 'block-bad-ua',
action: 'block',
reason: 'Known scanner UA',
when: {
field: 'headers.user-agent',
matches: /sqlmap|nikto|acunetix/i,
},
},
{
id: 'allow-health',
priority: 1,
action: 'allow',
when: { field: 'path', equals: '/health' },
},
],
}),
);
app.get('/api/items', (_req, res) => res.json([]));
app.listen(3000);The same rules / presets / level work with Fastify (config) and Nest (MiniWafModule.forRoot({ config })).
Troubleshooting
| Symptom | Common cause | What to do |
|---------|--------------|------------|
| Unexpected blocks / many FPs | level too high (high / paranoid) | Drop to balanced or low; narrow presets; use disabledRuleIds or allow with low priority for legitimate routes |
| Body rule never fires | WAF before body parser (or no parser) | Express: express.json() / urlencoded before the WAF; Fastify: rely on the built-in parser and register the plugin before routes; Nest: apply middleware after the platform's default setup |
| Query/headers OK, body "empty" in WAF | req.body not populated yet | Check middleware order; in a custom adapter, getRawBody must read the already-parsed or raw payload |
| Health route blocked | Broad preset/scanner rule | Whitelist: { id: 'allow-health', priority: 1, action: 'allow', when: { field: 'path', equals: '/health' } } |
| Nest does not block / allow | Middleware not applied | consumer.apply(MiniWafMiddleware).forRoutes('*') in NestModule |
| Requests carrying a remote URL get 403 | preset-rfi-remote-url fires on URLs ending in .php, .txt, .jsp… or a trailing ? | disabledRuleIds: ['preset-rfi-remote-url'], or move the URL to the request body |
| A sort/report endpoint gets 403 at high | preset-sqli-blind (ORDER BY 1, CASE WHEN) | Stay on balanced, or disabledRuleIds: ['preset-sqli-blind'] |
| ?user[$ne]=null is not blocked | Query names are not scanned, and Express 5's default parser keeps the payload in the key | app.set('query parser', 'extended') so the nested object is flattened and inspected |
| Custom rule stopped matching | An incomplete requires list is filtering the value out before matches runs | Remove requires, or add every literal your pattern can match on |
Development
npm install
npm run build
npm testSupport the project
Mini-WAF is MIT-licensed and maintained in the open. If it protects something you run in production, you can support its maintenance on Ko-fi — donations fund rule-set curation, the documentation site, and the time that keeps both current.
License
MIT
