
@apitraffic/waf is an opt-in Web Application Firewall that runs in-process inside your Node application. Policies are authored in ApiTraffic and pushed to every connected server in real time, over the same backbone that already delivers your data-redaction rules - so a rule change takes effect across your fleet without a redeploy.
All of the firewall's weight lives in this package. The base client stays lightweight for everyone who does not want it.
Contents
Do I need to install this?
Already using @apitraffic/node (or @apitraffic/nestjs, -meteor, -loopback, -adonisjs, -sails, which build on it)? The WAF is bundled and mounted for you. Enable it from your ApiTraffic account and skip the installation and code sections below; pass any of the local options as apitraffic.express({ waf: { ... } }). The engine is loaded the first time a policy that is switched on arrives, so until then it costs one check per request.
Install @apitraffic/waf yourself when you use a per-framework package (@apitraffic/express, -fastify, -koa or -hapi).
Features
- Managed detection for the OWASP Top 10 injection families - SQLi, XSS, RCE, path traversal, SSRF, SSTI, NoSQLi and XXE.
- CRS-style anomaly scoring and paranoia levels, so you can tune sensitivity rather than toggling rules one at a time.
- IP and geo lists, structural limits, and rate limiting: per instance by default (each server counts only the requests it receives, so a limit of 5 per minute across 4 servers admits up to 20 per minute in total), or fleet-aware (approximate) - see Rate limiting.
- Egress control - inspect and block outbound calls, including secret and bulk-PII exfiltration.
- Real-time policy push, with no second API connection: it rides the one the base client already holds.
Requirements
- Node 20+ (
engines: >=20.0.0).
- One of
@apitraffic/express, @apitraffic/fastify, @apitraffic/koa or @apitraffic/hapi, already installed and mounted (2.x, like this package). The WAF sources its policy from the settings that package loads, and records its detections on the request that package captures.
- A plan that includes the Web application firewall. Without it no policy reaches your servers and the WAF stays inert.
- Outbound HTTPS to
api.apitraffic.io, which the base client already needs.
There are no peer dependencies and nothing native to compile.
Installation
npm install @apitraffic/waf
pnpm add @apitraffic/waf
yarn add @apitraffic/waf
Add code to your application
Mount the WAF after the ApiTraffic middleware and before your routes. That order matters: the base client establishes the request context, so any detection the WAF records is attached to the request that caused it. On Express and Koa, mount your body parser before the WAF too, so body rules can inspect the parsed body; the WAF warns once on the console when a request with a JSON or form body reaches it unparsed.
Express
const express = require('express');
const apitraffic = require('@apitraffic/express');
const waf = require('@apitraffic/waf');
const app = express();
app.use(express.json());
app.use(apitraffic.middleware());
app.use(waf.express());
app.get('/', (req, res) => res.send('ok'));
Fastify
const fastify = require('fastify')();
const apitraffic = require('@apitraffic/fastify');
const waf = require('@apitraffic/waf');
fastify.register(apitraffic.middleware);
fastify.addHook('preHandler', waf.fastify());
Koa
const Koa = require('koa');
const { bodyParser } = require('@koa/bodyparser');
const apitraffic = require('@apitraffic/koa');
const waf = require('@apitraffic/waf');
const app = new Koa();
app.use(bodyParser());
app.use(apitraffic.middleware());
app.use(waf.koa());
Hapi
const Hapi = require('@hapi/hapi');
const apitraffic = require('@apitraffic/hapi');
const waf = require('@apitraffic/waf');
const init = async () => {
const server = Hapi.server({ port: 3000 });
await server.register({ plugin: apitraffic.plugin });
server.ext('onPreHandler', waf.hapi());
};
init();
Hapi has no "trust proxy" setting: request.info.remoteAddress is the socket's peer, which behind a load balancer or proxy is that hop, not the client. There, give @apitraffic/hapi (2.4.0 or later) a clientIp that reads the header your edge sets. Trust that header only if your server accepts connections from the edge alone: reached directly, a client can send any value. From 2.4.0 the WAF enforces against the address it resolves:
await server.register({
plugin: apitraffic.plugin,
options: { clientIp: (request) => request.headers['do-connecting-ip'] },
});
server.ext('onPreHandler', waf.hapi());
AdonisJS
Use @apitraffic/adonisjs, which mounts this package's waf.adonis() middleware after the AdonisJS body parser for you.
Configuration
Policy - rules, paranoia level, thresholds, lists, rate limits, block responses - is configured in your ApiTraffic account and pushed to your servers. There is nothing to redeploy when it changes.
The options below are the local controls, and exist mainly as kill switches. Pass them to the framework middleware (waf.express({ ... }), waf.fastify({ ... }), …) or to waf.configure({ ... }). With @apitraffic/node, pass them as its waf option instead.
enabled | API_TRAFFIC_WAF_ENABLED | boolean | true | Master kill switch. false makes the WAF a no-op without removing it. The WAF runs only while both the option and the variable allow it: the variable set to exactly false or 0, or off in any case, turns it off whatever the option says. |
debug | - | boolean | The base client's debug when mounted | Log firewall errors to the console (a policy that fails to compile, an evaluation or outbound check that throws). It logs no decisions: blocks and detections go to onEvent. Unset, it takes the base client's debug value when the firewall is mounted, which is usually before the SDK has read API_TRAFFIC_DEBUG, so pass debug: true to be sure. |
offlineWafMode | API_TRAFFIC_OFFLINE_WAF_MODE | 'inherit' | 'open' | 'closed' | 'inherit' | What a server that has never loaded a policy does: 'inherit' or 'open' allow traffic, 'closed' refuses every request with 503. The environment variable overrides the option. See the warning below. |
onEvent | - | function | - | Called for every inbound block or detection. See Events. |
onEgressEvent | - | function | - | Called for every outbound block or detection. |
app.use(waf.express({
enabled: process.env.NODE_ENV === 'production',
onEvent: (event) => myLogger.warn('waf', event),
}));
The WAF is one engine per process: options passed to any of the middleware factories, or to configure(), apply to it as a whole, and each call replaces the previous one's onEvent and debug (an omitted onEgressEvent, enabled or offlineWafMode is kept). When you mount it more than once, pass the same options every time, or waf.express() with none clears the onEvent an earlier call set.
Environment variables
API_TRAFFIC_WAF_ENABLED | this package | Exactly false or 0, or off in any case, disables enforcement with no code change and no deploy: restart the process with it set (it is read on every request, so a change to process.env inside the running process applies at the next request). Any other value (including FALSE, no or a padded value), or unset, leaves the enabled option in charge. |
API_TRAFFIC_OFFLINE_WAF_MODE | this package | inherit, open or closed. Wins over the offlineWafMode option, so you can flip it during an incident without a deploy. Only exactly closed (lowercase) means closed: any other non-empty value, such as CLOSED, still overrides the option and fails open. |
API_TRAFFIC_DEBUG | the base client | true turns on the SDK's debug logging. The firewall follows it only if it is mounted after the SDK has set up; pass debug: true to the firewall to be sure. |
API_TRAFFIC_DEFINITIONS_PATH | the base client | Path of the pinned definitions file. Unset, the SDK looks for .apitraffic/definitions/sdk.json (or sdk.<bucket>.json) in your project. When set, it is the only place it looks. |
API_TRAFFIC_CACHE_DIR | the base client | Where the last-known-good copy of your settings, including the WAF policy, is kept. Default node_modules/.cache/apitraffic. |
API_TRAFFIC_FALLBACK_MAX_AGE_DAYS | the base client | Skip a pinned file older than this many days. Unset, there is no limit (a warning is logged past 30 days). |
The last three decide whether a server that boots while ApiTraffic is unreachable still has a policy to enforce. On the cache, mirror and pinned tiers the WAF has a real policy and behaves exactly as it does live; offlineWafMode only matters when none of them is available. See Offline definitions.
Turning it off in a hurry
API_TRAFFIC_WAF_ENABLED=false disables enforcement with no code change and no deploy (restart the process with it set), and works even when ApiTraffic is unreachable. Setting the policy's Request Protection to Not monitored (mode: 'off'), or turning the policy off, in ApiTraffic does the same thing across your whole fleet.
Modes: detection without blocking
A policy runs in one of three modes, set in ApiTraffic:
detect | Monitor | Scores and records matches, and allows every request through. |
block | Enforcing | Refuses requests that match a blocking rule or reach the anomaly threshold. |
off | Not monitored | Inert; the policy is not delivered. |
detect is the recommended way to introduce the WAF to an existing application: run it for a while, review what it would have blocked, then switch to block once the findings look right. See Set up the firewall.
Block responses
A blocked request returns the status and body defined by your policy (403 Forbidden by default) and never reaches your route handler. Rate limits answer with their own status (429 by default) and RateLimit-* headers. A block response can include {context_id}, which is filled in with the blocked request's ApiTraffic context id so a caller can quote it to you. See Block responses.
Failure behaviour
The WAF is designed on the principle that a firewall must never be the reason an application stops working.
- If rule evaluation throws for any reason, the request is allowed and passed to your handler, tagged
waf.error=internal. Only a policy in block mode with failMode: 'closed' refuses it instead; detect and off never refuse a request.
- After 10 consecutive internal errors, even a
failMode: 'closed' policy allows the requests it fails on, for 30 seconds, and logs it once. Every request is still evaluated, and one that evaluates cleanly gets its normal decision, so this never switches inspection off. If errors continue after the 30 seconds, it re-opens at the next one. The trade-off is deliberate: an input that makes the engine throw is a bug in the engine, and a fleet that refused everything for it would be an outage. It does mean a request that reliably triggers such a bug gets through a fail-closed policy once ten in a row have failed - report it, and it will be fixed.
- Every policy is validated before it replaces the one in force. A malformed rule (for example one whose
targets is not a list) is dropped and reported once per policy version, and the rest of the policy is enforced. Only a structurally corrupt policy (for example an unknown mode or failMode) is refused, logged once, and the previous policy stays in force. Values this version does not know - a new operator, target or transform - are not errors: those rules simply never match.
- A rate limit whose
pathPattern does not compile is dropped (and logged), never applied to every path. One that compiles but cannot run safely is applied to a superset of its paths (see Your own patterns).
- A user-agent or path allow-list none of whose entries can run is not enforced (logged once per policy version, requests tagged
waf.listNotEnforced), rather than refusing every request.
- If no policy has ever loaded, the WAF is inert - it does not guess - unless
offlineWafMode is 'closed'. Once settings have loaded, a bucket with no WAF policy (none configured, or removed or disabled) simply allows traffic, whatever offlineWafMode says.
- If the outbound guard throws, the outbound call proceeds.
- The kill switches above need no code change or deploy:
API_TRAFFIC_WAF_ENABLED is read on every request, so it applies as soon as the process sees it (a variable changed outside a running process needs a restart to reach it).
Warning: offlineWafMode: 'closed' can take your application down during an ApiTraffic outage.
A server that boots while ApiTraffic is unreachable, with no cached or pinned copy of your policy, refuses every request with 503 until a policy loads. It is the only setting with which an ApiTraffic outage stops your application serving traffic. (Removing or disabling the WAF policy does not trigger it: that is a loaded decision, not an outage.) If you use it, commit pinned definitions so a fresh container always has a policy. See If ApiTraffic is unreachable.
Inspection limits
The WAF runs on your request path, so the work it does per request is capped whatever the request carries. A cap never makes part of a request silently invisible: what is not inspected is reported.
| One value (argument, header, cookie, …) | 64 KB | The first 32 KB and the last 32 KB are inspected, each on its own | AT-LIMIT-001 |
The whole body (limits.maxBodyInspectBytes) | 128 KB (8 KB–1 MB) | The first and last halves are inspected | AT-LIMIT-002 |
| Values per source (query, body, headers, …) | 512 | The rest are not inspected | AT-LIMIT-003 |
| Body nesting | 12 levels | Deeper values are not inspected | AT-LIMIT-004 |
| Inspected text per target | 256 KB | The rest is not inspected | AT-LIMIT-005 |
Time spent on your own patterns (limits.customerPatternBudgetMs) | 25 ms (5–500) per phase | The rest of the phase's patterns are not evaluated (see below) | AT-LIMIT-006 |
The AT-LIMIT-* findings have category inspection-limit and severity NOTICE. By default they score 0: they are recorded (in detect mode, and as tags in block mode) but never block a request, on their own or added to other matches, so large legitimate uploads are unaffected. To enforce them through the anomaly score, set limits.inspectionLimitScore (2 matches CRS NOTICE); the family then adds that score once per request. They can be disabled or excluded like any managed rule.
Outbound (egress) payloads are scanned completely up to 256 KB, in overlapping 64 KB chunks, and on their first and last 128 KB beyond that. The egress size limit (maxBodyBytes) applies to the full payload size, not just the part that was captured for inspection.
Geo lists
Country lists (allow or deny), and custom criteria rules on country, work with or without an edge in front of your application.
Without an edge. ApiTraffic compiles the IP address ranges of the countries your policy names into the policy it sends, and the WAF looks the client IP (request.ip, as your framework reports it) up in them: a binary search, about 0.1 to 0.15 µs per request for IPv4 and 0.25 to 0.4 µs for IPv6, with no allocation, and nothing to install or download. The ranges are DB-IP's IP to Country Lite data (IP Geolocation by DB-IP, licensed CC BY 4.0), refreshed when DB-IP publishes a new month; your servers receive the new ranges with a policy reload. Check that request.ip is the client's address: behind a proxy, configure your framework's trusted proxies (Express trust proxy, Fastify trustProxy, Koa proxy, Hapi the clientIp option of @apitraffic/hapi, see Hapi).
With an edge. If Cloudflare, or an equivalent proxy or load balancer, adds the client's country as a request header (overwriting any value the client sent), name it in the policy: geo.countryHeader (for example cf-ipcountry). Where a request carries that header, its value is used; where it does not, the client IP decides. ASN lists need a header (geo.asnHeader): there is no ASN lookup. The WAF reads only the header you configured; it does not fall back to any other.
- A public address in none of the policy's countries is not on any allow-list: a "US only" list refuses it. It matches no deny-list.
- A request with no client address, or a private or reserved one (
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, loopback, link-local, fc00::/7, multicast), has no country: country lists skip it, as they did before lookups existed, so a load balancer's health check is not refused. The WAF logs it once per policy load, without the address. A client cannot choose this: the address is what your framework reports. If every request is skipped, request.ip is your proxy's address; see above.
- A criteria rule compares
country with the code of the client's country: country == "US", country != "US" (upper-case ISO 3166-1 codes, == and != only). A public address in none of the policy's countries is "some other country": country != "US" matches it. A request with no country (no address, or a private one) matches no condition.
- A policy can carry about 2 MB of ranges (the United States, the largest, is about 0.8 MB). Over that, the control plane sends none and says so with the policy; its country lists then work with a header only, and its criteria rules on
country are sent turned off.
- With neither ranges nor a header, country lists are not enforced at all - neither allow nor deny - and the WAF says so on every policy load.
- The header is trusted as your edge sets it. If clients can reach your application without passing through that edge, they can send the header themselves: restrict direct access (Cloudflare Authenticated Origin Pulls, an allow-list of the edge's ranges, or a private network).
New in 2.1.0: country lookups by client IP. Earlier versions read country lists only from the configured header (and do not enforce them without one), and receive criteria rules on country turned off.
Changed in 2.0.0: country lists used to read cf-ipcountry or x-country, and ASN lists x-asn, from any client, and skipped the list when the header was absent. So an allow-list was bypassed by omitting the header or sending X-Country: US. Policies with country or ASN lists must name the header (or, from 2.1.0, carry their countries' ranges); until they do, those lists are not enforced (an upgrade never starts refusing traffic), and every policy load logs a warning naming them.
Your own patterns
Regular expressions you write - custom rx rules, user-agent and path lists, and the path patterns of rate limits and rule exclusions - run inside your application, so their cost must not depend on what an attacker sends:
- A pattern that could backtrack runs on a linear-time engine. Only patterns in a narrow class whose backtracking is linear by construction (literals,
^/api/v[0-9]+/, AKIA[0-9A-Z]{16}, \d{3}-\d{2}-\d{4}, a repetition whose end is fixed by the next character) run on JavaScript's RegExp. Every other pattern - whether or not the backtracking analysis accepts it, and including patterns saved before the control plane checked ((a+)+$, \w{0,64}\w{0,64}\w{0,64}@, ^/api/.*/users/.*/orders$) - runs on the WAF's own regular-expression engine (a lazily built DFA over a Thompson NFA, in pure TypeScript, no native dependency) whose cost is proportional to the length of the value whatever the pattern, so it is never dropped or cut down, and one test never takes more than a few milliseconds. (The analysis is a heuristic: it accepted patterns such as [a-z0-9]{1,64}[a-z0-9]{100}= that take half a second per 16 KB value on RegExp, and a single test cannot be interrupted.) It gives exactly the result RegExp would (test), for the syntax and flags (i, m) patterns use, and its caches share one 16 MB budget across every pattern in the process.
- Lookarounds and backreferences (
(?<=…), (?!…), \1) need RegExp. Such a pattern runs if the backtracking analysis accepts it; one the analysis flags (quadratic, polynomial or exponential: it cannot be trusted with the degree, so no window is safe) is not run, and the WAF logs which ones once per policy version. A deny-list entry then matches nothing. An allow-list stays enforced without the entry, so a request only that entry would have admitted is refused (fail closed; this used to leave the whole list unenforced). If none of an allow-list's entries can run, enforcing it would refuse every request, so that list is not enforced: the WAF logs it as an error once per policy version and tags every request it inspects meanwhile waf.listNotEnforced: <list ids>. A rate limit is applied to the paths its pattern matches with the lookarounds removed and each backreference read as "anything" - every path it matched and possibly some more - unless that would cover most of the site, in which case it is not applied (never site-wide): "most" is 5 or more of /health, /healthz, /api, /api/public, /a.b, /index.html, /static/app.js, /x/y/z and /, so ^/(?!health$)(\w+/?)+$ (which would also limit /health) is not applied. A rule exclusion with such a path pattern is not applied, so the rules it would have suppressed still run.
- Path patterns are matched against the decoded path. Write
^/files/my report, not ^/files/my%20report; matching is case-insensitive. A deny-list or a rate limit matches if the path as sent or its normalised form (decoded once, ;params dropped, // collapsed, ./.. resolved, trailing / optional) matches. An allow-list admits, and a rule exclusion applies, only if every form matches - the path as sent (decoded once), the normalised path, and the path cut at the first ; - because routers disagree on which one they route: Express, Koa and Hapi do not resolve .. (/admin/x/../../public reaches an /admin mount) and Fastify 4 routes /admin;/../public as /admin. So ^/public does not admit or exclude either of those, nor //public (write ^/+public if you need it).
- The control plane refuses a pattern that can backtrack catastrophically when you save it, because SDKs 0.1.0 and 0.1.1 run every pattern on
RegExp. A pattern saved before that check existed is kept, and the API returns a pattern-unsafe-on-older-sdks warning with the policy (the dashboard shows it) so you can rewrite it or upgrade those SDKs.
- Each request has a time budget for them (
limits.customerPatternBudgetMs, 25 ms by default, 5–500), measured separately for custom rx rules and for user-agent and path lists, so expensive rules can never use up the lists' share. Past it, the rest of that phase is not evaluated for that request and the request carries one AT-LIMIT-006 finding and the tag waf.customerPatternBudget: exceeded. A pattern that was not evaluated never decides in the attacker's favour: a custom rule not run does not match, a deny-list entry not tested does not block, and an allow-list whose tested entries did not match refuses the request even if an untested entry would have admitted it. Managed rules always run. Under failMode: 'closed' in block mode, a request that exceeded the budget is refused (reason customer-pattern-budget-exceeded).
- Custom rules always run on every small value and on the start and end of every larger one, whatever the budget: on every value up to 4 KB (headers, parameters, cookies, fields of ordinary size), and on the first 8 KB and the last 8 KB of every larger value (a value up to 16 KB is therefore always inspected whole). For a rule on the
body target the whole body is one value, so its first and last 8 KB are always inspected. The first and last 8 KB are inspected each on its own, like any cut piece: ^ and $ never match where the value was cut, and a negated rule (or one on the length transform) is not run on a piece. Padding a request therefore cannot push a later rule past an attack in an ordinary-size value or at either end of a large one.
- The budget is spent on the middle of larger values. While it lasts, a rule runs on the whole of a large value in one pass, so a match that crosses the 8 KB boundaries is found as in any other value. Once it is used up, the remaining rules run on the remaining large values' first and last 8 KB only, and the request is allowed and reported (
AT-LIMIT-006, waf.customerPatternBudget: exceeded) in block and detect mode alike, however large the value - a legitimate upload is never refused for its size because the policy is expensive. Only failMode: 'closed' in block mode refuses it (as for any budget overrun). Accepted trade-off: an attacker who puts enough data in front of it to spend the budget can place a payload in the middle of a large value (beyond its first and last 8 KB) where later custom rules do not see it. Managed rules always run on the whole of every value, within the inspection limits above, so they still inspect it; if your own rules on large bodies must see all of them, raise customerPatternBudgetMs or use failMode: 'closed'.
- The always-run part is bounded. It reads each extracted character at most once per rule, so it is bounded by the extraction limits (512 values and 256 KB per target) and by every pattern running in bounded time. If it reaches ten times the budget - a policy near the control plane's size limits over a request near those limits, or many large values whose first and last 8 KB are slow for an expensive pattern - it stops, and in
block mode the request is refused, whatever the fail mode (the largest policy the control plane accepts, over ~3,000 values, is refused after ~0.35 s instead of running for 2–18 s).
- In
block mode, whatever the fail mode, running out of budget in the lists on a request that carries an abnormally large list subject is refused: a User-Agent over 1 KB or a path over 4 KB, wherever in the request it is (a padded path that uses the budget up in a path list does not let a later user-agent deny-list be skipped). Padding is how later entries would be pushed past the budget, and legitimate clients do not send a User-Agent or a path that size. A request of normal-size values that runs out of list budget is still allowed and reported.
- Real traffic stays far below the budget; it bounds what a crafted request costs (a 1 MB request against five large custom rules took 2.7 s).
- A leading or trailing
.* is removed (.*bot.* means exactly "contains bot").
- List and path patterns see up to 16 KB of a value; a longer value is matched on its first and last 8 KB, each on its own, and
^/$ never match where the value was cut. Custom rx rules see up to 64 KB, as described under Inspection limits. A match's evidence is the match itself, from where it starts (up to 120 characters), on either engine: never the value before it.
Managed ruleset revisions
A policy runs a managed ruleset revision, either pinned or tracking latest. Published revisions never change: a pinned policy keeps detecting exactly what it was tuned against, and new behaviour arrives only by moving the pin.
apitraffic-core@1.1.3 (latest): adds AT-NOSQLI-003 (PL1) for the NoSQL login bypass sent as bracketed query parameters (GET /login?user=admin&password[$ne]=x, which a qs-style parser turns into { password: { $ne: 'x' } }): a parameter name with a MongoDB query or update operator or aggregation stage as one of its segments, read as qs reads it (password[$ne], password.$ne, role[$in][], $or[0][role]). A root name counts only for an operator Mongo accepts at the top of a filter, an update or a pipeline stage ($where, $or, $set, $lookup…, not $size). The operators Strapi accepts under its own filters and populate parameters are not read; APIs whose own query syntax uses such operators elsewhere, such as Feathers (?createdAt[$gt]=…), need an exclusion for AT-NOSQLI-003. Every 1.1.2 rule is unchanged; SDKs released before this revision skip the new rule's operator.
1.1.2: adds AT-RCE-020 (PL1) and AT-RCE-021 (PL3) for an OS command on a line of its own (report.pdf\nuname -a, x%0awhoami, foo\ncat /etc/passwd). Every earlier rule is unchanged - AT-RCE-001/002/003 still fold line breaks into spaces - so SDKs released before this revision, which receive it too, behave exactly as before (they do not know the new rules' operator and skip them). PL1 flags only unambiguous injection: a lower-case command name (quotes and backslashes inside it removed) followed by a chained or substituted command (| sh, && id, ; ls, $(…), backticks; kill $(pgrep …) excepted), a spawned or reverse shell (-e sh, bash -c '…', bash -c id, bash -i, /bin/sh, a descriptor opened on /dev/tcp/ as in exec 5<>/dev/tcp/…, busybox nc … -e sh, inline interpreter code that opens sockets or runs commands), a sensitive file (/etc/passwd, /etc/shadow, /etc/sudoers, a private SSH key such as ~/.ssh/id_rsa, .aws/credentials, /proc/self/environ) read or sent (by cat, head, base64, scp, nc and the like, uploaded (curl -d @…, -F x=@…, -T, wget --post-file=), redirected into a command, piped on, or copied into the web root; only named (ssh-add ~/.ssh/id_ed25519, chmod 600 ~/.ssh/id_rsa, vim ~/.aws/credentials) it is AT-RCE-022's), a redirect into a sensitive location (/etc/, ~/.ssh/, /root/, cron spools), or a name obfuscated with quotes or backslashes (w'h'oami). A download piped into a shell (curl … | sh) is PL1 even in an install guide: it cannot be told apart from an injected one. AT-RCE-022 (PL2) flags what bug reports, READMEs, runbooks and CI logs carry as well, and never scores a line AT-RCE-020 already does: other system paths (cat /etc/os-release, ls -la /etc/nginx, cat /proc/meminfo, cat ~/.ssh/id_rsa.pub, unzip x.zip -d /tmp/x), reconnaissance alone or with a flag (uname -a, id, whoami), kill $(pgrep node), an interpreter's inline code (python3 -c '…'), any other redirect (> /tmp/x, 2>&1, >> notes.txt, a server script written under the web root), cat .env, ls /var/www, ~/.aws, find . -exec …, tar czf …. AT-RCE-021 (PL3) also flags any command name alone on its line or followed by a flag or path.
Also in 1.1.2: AT-RCE-017 (nested Log4j lookups), AT-SQLI-018 (SQL in MySQL versioned comments), and AT-XSS-017 (PL1) for a javascript:/vbscript: scheme hidden with a tab, line feed or carriage return inside its name (java	script:), when the payload starts as script: it is read as an expression, and an operand of it is executed - a name or ./?.-chain called, tagged, indexed or assigned, also after a comment or (for a .-chain, or a sink that is not also an English word, such as alert, eval, setTimeout) a space (alert(1), alert?.(1), alert/**/(1), alert (1), self.self.….alert(1); open (file) and print (x) are text at PL1), after unary operators, void/typeof/new/delete/await/async, binary and conditional operators and their operands (x||alert(1), 1-alert(1), 0?0:alert(1), ''+alert(1)), a parenthesised expression or an indexed array literal called ((alert)(1), [alert][0](1)), or a template literal's ${…}. A skills list that spells Java\nScript: is text. AT-XSS-018 (PL2) also flags a hidden scheme followed by script-like punctuation (void 0, 1+1+…alert(1)); it flags text such as Java\nScript: (ES2020) too.
1.1.1: ReDoS-safe rewrites of patterns that could take seconds on a crafted value; detects exactly what 1.1.0 does.
Rate limiting
Rate limits are counted in memory, per server, with a fixed cost per request however many clients there are: at most 50,000 counters, each with a bounded key. When the table is full, the least recently used counter is evicted, but a client that is currently over its limit keeps its counter until its window closes, so a flood of new clients cannot reset it. IPv6 clients are counted per /64 (one host or subscriber is routinely given a whole /64). Header-keyed limits store a hash of the header value, never the value itself.
A request is counted once even when it is evaluated twice (the middleware mounted on the app, and again on a router after its body parser). If you call waf.evaluate(request) yourself for a request that was already evaluated, pass { countRateLimits: false }: its rules run again, its rate limits are neither counted nor checked again.
Fleet-aware (approximate) limits
A rate limit with scope: 'fleet-approx' treats its limit as a limit for the whole fleet. Each server enforces ceil(limit / N), where N is the number of servers currently connected to the bucket that run the WAF, read from the realtime presence the SDK already maintains (no extra connection; each server announces once that it runs the WAF). Processes on the same bucket that do not mount the WAF - workers, cron jobs - are not counted, since they serve no inbound traffic. N is recounted when a server joins or leaves, or starts or stops its WAF, at most once every 5 seconds. When it cannot be known - no realtime connection, for example a server running on pinned definitions - N is 1, which is the per-instance limit.
During an upgrade, servers on an SDK version that predates this announcement are not counted: N is then lower than the real number of enforcing servers, so each server's share is larger and the fleet-wide limit looser than configured - never stricter.
It is an approximation, and it assumes your load balancer spreads each client's requests evenly across servers:
- With sticky sessions or skewed routing, a client whose requests all reach one server is limited to that server's share (
ceil(limit / N)), not the whole limit.
- Rounding up means the fleet admits up to N ×
ceil(limit / N) per window, for example 20 per minute for a limit of 5 across 20 servers.
- Every SDK process on the bucket counts, including ones serving other environments of the same bucket.
- N is only as trustworthy as the API tokens that can reach the bucket. Each token can hold presence for up to 500 server ids at once, so someone holding a leaked token for the bucket can add up to 500 fake servers per token. That cannot loosen a limit - it only raises N, making each real server's share (
ceil(limit / N)) smaller, down to 1 per window - but it can make a fleet-approx limit throttle legitimate clients. Treat API tokens as secrets, rotate one you suspect has leaked (its servers drop out of the count within an hour, when their realtime tokens expire), and use scope: 'instance' for a limit that must not be affected by anything outside your servers.
scope: 'instance' (the default) is unchanged. Exact fleet-wide counting is on the roadmap.
Events
onEvent is called for every inbound block or detection with { action, ruleId, reason, score, matches, ip?, method, path }, where action is 'block' or 'detect' and each entry in matches carries ruleId, category, message, severity, score and, where known, target, a short redaction-safe data snippet and source (managed or custom). onEgressEvent receives { action, ruleId, reason, matches, method, host, path?, provider? } for outbound calls. Both are optional: every decision is already recorded on the request in ApiTraffic.
Programmatic API
Everything the middleware does is available directly, for hosts that need it:
express(options?), fastify(options?), koa(options?), hapi(options?), adonis(options?) | The framework middleware, as above. |
configure(options?) | Apply the local options and wire the WAF to the base client's settings, without mounting middleware. |
evaluate(request, options?) | Evaluate a framework-agnostic request ({ ip?, method, path, url?, query?, headers?, cookies?, body? }, headers lower-cased) and return a WafDecision ({ action: 'allow' | 'tag' | 'block', score, matches, tags, block? }). Never throws. Pass { countRateLimits: false } for a request that was already evaluated once, and contextId to fill a block's {context_id}. |
loadPolicy(policy) | Load a compiled policy by hand (advanced and testing). null clears it. |
bindBaseClient(utilities) | Run against a given @apitraffic/utilities instance instead of the one this package resolves by name. Only needed when two copies of the SDK are installed; @apitraffic/node does it for you. |
engine(), egress() | The process's inbound and outbound engines, for inspection. |
The engine classes (WafEngine, EgressEngine, RateLimiter), the detectors (detectSQLi, detectXSS, detectSecrets, detectBulkPii), destination helpers (isModelDestination, providerFor) and block-response helpers (fillBlockContextId, withContextId, BLOCK_CONTEXT_ID_PLACEHOLDER, …) are exported too. TypeScript declarations ship with the package, including WafConfigureOptions, InboundRequest, WafDecision, WafMatch, WafPolicy, EvaluateOptions, WafEventPayload and EgressEventPayload.
const waf = require('@apitraffic/waf');
const decision = waf.evaluate({
method: 'GET',
path: '/search',
query: { q: "1' OR '1'='1" },
headers: { 'user-agent': 'curl/8.0' },
});
if (decision.action === 'block') {
}
Troubleshooting
Pass debug: true to the firewall to log its errors (a policy that fails to compile, an evaluation that throws). Decisions are not logged: blocks and detections go to onEvent.
Nothing shows on the bucket's WAF page. Check that the policy is enabled, in Monitor or Enforcing mode, covers this bucket and environment, and is not overridden by a more specific policy. Check that the WAF is mounted after the ApiTraffic middleware and before your routes, that API_TRAFFIC_WAF_ENABLED is not false, and that your plan includes the firewall.
Body rules never match. The body was not parsed when the WAF ran. On Express and Koa, mount your body parser before the WAF; on Fastify use the preHandler hook, and on Hapi onPreHandler. Look for the one-time ApiTraffic WAF: a POST request with a body reached the WAF before it was parsed warning.
"no policy could be loaded; requests are NOT being filtered." The server could not reach ApiTraffic at boot and found no cached or pinned copy of your settings. Commit offline definitions, or check outbound HTTPS to api.apitraffic.io.
Country or ASN lists have no effect. Lookups by client IP need @apitraffic/waf 2.1.0 or later and a request.ip that is the client's address (configure your framework's trusted proxies). ASN lists, and earlier versions, need the policy to name the header your edge sets. See Geo lists.
Legitimate requests are blocked. Switch the policy back to Monitor while you investigate, then add a false positive exclusion for the rule on that path or field, or lower the paranoia level.
Licensing and third-party data
@apitraffic/waf is released under the MIT License. The package also ships a NOTICE file for the country data its policies can carry:
- The package itself contains no IP geolocation data.
- Firewall policies that ApiTraffic sends to it can carry the IP address ranges of the countries a policy names, taken from DB-IP's IP to Country Lite database - IP Geolocation by DB-IP. That data is (c) DB-IP.com, licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0). The ranges are carried as received, in a compact encoding, with no data added by ApiTraffic, and are provided as is, without warranty of accuracy.
If you redistribute this package, keep the NOTICE file with it.
Documentation and related packages
Changelog and license
Release notes are in CHANGELOG.md. Released under the MIT License; see Licensing and third-party data for the NOTICE.
Follow @apitraffic/waf on npm to stay updated on new releases.
Follow us on our Blog and on X.
Chat with the ApiTraffic team and other members on Discord and follow our tutorials and other videos on YouTube.



How to contribute
Here are some ways you can help improve ApiTraffic:
- Give ApiTraffic a try and share your feedback to help make ApiTraffic even better for you. Feel free to reach out to us on Discord!
- Join our Discord to connect with other members, share your experiences, and learn from one another.
- Report bugs or request features by emailing support@apitraffic.io or asking on Discord.