brainstate

MurylloEx/Mini-WAF · source

Snapshot 0 · 2026-11-04T12:47:26Z · TypeScript · MIT

Content fingerprint1af48b636d9535c90090f9cf2a0ef6a0c7f5a152e76e72758e0de77e557a8e8a

GitHub description at capture

An agnostic Minimalistic Web Application Firewall for your purposes in NodeJS servers.

README at capture

<p align="center">
  <img src="./.github/assets/mini-waf-logo.png" alt="Mini-WAF — Web Application Firewall" width="420"/>
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/mini-waf"><img src="https://badgen.net/npm/v/mini-waf" alt="npm version"/></a>
  <a href="https://www.npmjs.com/package/mini-waf"><img src="https://badgen.net/npm/dt/mini-waf" alt="npm downloads"/></a>
  <a href="https://www.npmjs.com/package/mini-waf"><img src="https://badgen.net/npm/license/mini-waf" alt="license"/></a>
  <a href="https://www.npmjs.com/package/mini-waf"><img src="https://badgen.net/npm/types/mini-waf" alt="types"/></a>
  <img src="https://badgen.net/badge/node/%3E=18/green" alt="node"/>
  <a href="https://github.com/MurylloEx"><img src="https://badgen.net/badge/author/MurylloEx/red?icon=label" alt="author"/></a>
  <a href="https://ko-fi.com/murylloex"><img src="https://badgen.net/badge/support/ko-fi/red" alt="support on Ko-fi"/></a>
</p>

# 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/](https://mini-waf.vercel.app/)

| | |
|---|---|
| **Get running** | [Quick start](https://mini-waf.vercel.app/guide/quick-start) · [Express](https://mini-waf.vercel.app/guide/integrations/express) · [Fastify](https://mini-waf.vercel.app/guide/integrations/fastify) · [NestJS](https://mini-waf.vercel.app/guide/integrations/nestjs) · [Custom adapters](https://mini-waf.vercel.app/guide/integrations/custom-adapters) |
| **Understand it** | [Core concepts](https://mini-waf.vercel.app/guide/concepts) · [Conditions & matchers](https://mini-waf.vercel.app/guide/conditions) · [Protection levels](https://mini-waf.vercel.app/guide/protection-levels) |
| **Write rules** | [Presets](https://mini-waf.vercel.app/guide/presets) · [Custom rules](https://mini-waf.vercel.app/guide/custom-rules) · [JSON rules](https://mini-waf.vercel.app/guide/json-rules) |
| **Operate it** | [Logging](https://mini-waf.vercel.app/guide/logging) · [Performance](https://mini-waf.vercel.app/guide/performance) · [Benchmarking](https://mini-waf.vercel.app/guide/benchmarking) · [Security notes](https://mini-waf.vercel.app/guide/security) |

## Install

```bash
npm install mini-waf
```

Peers per framework:

```bash
# 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

```ts
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

```ts
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

```ts
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`:

```ts
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)`.

```ts
{
  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, form and multipart body values), percent-encoding and JSON \u escapes, 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, a single JSON body value like `{"q":"<base64>"}`, or one form or multipart field of a raw body; **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), and also scans each string of a raw JSON body written with `\uXXXX` / `\/` escapes, decoded; **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 |

```ts
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

```ts
{ presets: ['default'] }
// same as: sqli + xss + scanners + path-traversal + rfi + rce + protocol
```

Individual 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](https://mini-waf.vercel.app/guide/presets).

Each preset rule already has a `minLevel`; your config `level` decides which ones run.

Only what you need:

```ts
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:

1. Resolve presets + custom `rules`
2. Filter by protection `level` (`minLevel`)
3. Apply `enabledRuleIds` (if present and **non-empty** — allowlist)
4. Apply `disabledRuleIds`
5. Drop `enabled: false` and sort by `priority`

```ts
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.

```ts
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

```ts
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 when `maxFieldLength` > 0)
- `equals`: exact equality
- `includes`: case-insensitive substring (needle lowercased at rule load)
- `requires`: literal prefilter — see below
- `rateLimit: { 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:

```ts
{
  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 `requires` out 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 at `info`+)

### 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`:

```ts
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

```ts
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

```bash
npm install
npm run build
npm test
```

## Support 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](https://ko-fi.com/murylloex) — donations fund rule-set curation, the documentation site, and
the time that keeps both current.

## License

MIT