Search docs

Jump between documentation pages.

Browse docs

WAF-lite signature/anomaly inspection

A full Web Application Firewall belongs at your edge — a CDN, reverse proxy, or ModSecurity with the OWASP Core Rule Set. DaloyJS does not try to replace that. But plenty of teams ship without an edge WAF, and for them waf() is a first-party, opt-in defense-in-depthlayer: it wires the framework's high-confidence injection signatures into a single, scored inbound-inspection pass you can turn on with one line.

As of 0.37.0, waf() inspects the decoded URL path, the raw and decoded query string, an optional header allowlist, and the validated request body for four rule categories — SQLi, XSS, NoSQLi (Mongo-style operator injection), and command injection. Each rule that fires contributes an anomaly score; when the total reaches the threshold, the request is rejected with a generic 403 (block mode) or merely reported (log mode).

Quick start

ts
import { App, waf } from "@daloyjs/core";

const app = new App();

// Register globally. Secure defaults: all four rules on, block mode,
// path/query/body inspected (headers are opt-in).
app.use(waf());

The middleware runs in the beforeHandle phase, so it sees the validated context — query, params, headers, and the schema-parsed body. Because body inspection reads ctx.body, it composes with the framework's schema-first contract: routes that declare a body schema are body-inspected automatically.

Tune in log mode first

Signatures are curated for a low false-positive rate, but every app is different. Start in "log" mode, watch onMatch against real traffic, then switch to "block" once you are confident.

ts
app.use(waf({
  mode: "log", // never rejects — only reports
  onMatch: (event) => {
    logger.warn({ waf: event }, "waf detection");
    // event = { mode, action, method, path, clientIp, score, threshold, matches }
  },
}));

onMatch fires once per actionable detection (score at or above the threshold) in both modes, immediately before any 403 is thrown. Each entry in event.matches carries the ruleId, the score it contributed, the location it matched (path, query, header, or body), and a short, control-character-stripped sample for your logs.

The rules

  • sqliUNION SELECT, boolean tautologies (OR 1=1), stacked statements (; DROP TABLE), time-based probes (SLEEP(), WAITFOR DELAY), INFORMATION_SCHEMA, xp_cmdshell, and file primitives.
  • xss<script> tags, javascript: URIs, inline event handlers (onerror=, onload=), and document.cookie exfiltration.
  • nosqli — Mongo operator strings ($ne, $where, …) and a structural check that rejects a parsed body containing any $-prefixed key, so {"password": {"$ne": null}} is caught even when no string value matches.
  • cmdi — shell metacharacters chaining into binaries (; rm, | nc, && curl), command substitution ($(...), backticks), and sensitive path access (/etc/passwd).

Scoring and the block threshold

Each rule contributes its score once per request(deduplicated across all inspected locations). The default score is 5 and the default blockThreshold is 5, so any single high-confidence signature trips the guard. Raise the threshold to require multiple independent categories before acting:

ts
// Require two independent rule categories (5 + 5 = 10 >= 8) to fire.
app.use(waf({ blockThreshold: 8 }));

// Reweight a single rule.
app.use(waf({ rules: { sqli: { score: 8 } } }));

Per-rule enable/disable

Disable a noisy rule with a boolean, or pass an object to enable it with a custom score. Omitted rules keep their defaults (enabled, score 5).

ts
app.use(waf({
  rules: {
    xss: false,            // turn XSS inspection off entirely
    sqli: { score: 8 },    // keep SQLi on, weighted higher
    // nosqli and cmdi keep their defaults
  },
}));

Inspection scope

Path, query, and body are inspected by default. Header inspection is opt-in and requires an explicit allowlist, because common headers (User-Agent, Cookie, Referer) carry punctuation that can trip signatures.

ts
app.use(waf({
  inspect: {
    path: true,
    query: true,
    body: true,
    headers: ["referer", "x-forwarded-host"], // only these headers are scanned
  },
}));

Scanning is bounded so a hostile payload cannot turn inspection into CPU-DoS: maxValueLength (default 8192) caps the length of any single scanned string, and maxBodyNodes (default 10000) caps how many body nodes are walked. Only own enumerable properties are followed — prototype keys are never inspected.

Security notes

  • The 403 body is intentionally generic (Request blocked by security policy) — it never tells an attacker which signature fired. Rule detail is delivered server-side via onMatch only.
  • This is a complement to input schemas and parameter binding, not a substitute. Keep validating with Zod schemas; the WAF is a second line for traffic that slips through application logic.
  • Routes without a body schema are not body-inspected (their body is never parsed). Add a schema to bring their inputs under coverage.
  • A WAF-lite is best-effort signature matching: determined attackers can craft evasions. Treat it as depth, and keep an edge WAF on your roadmap for high-risk surfaces.