Web Application Firewall

The Firewall is an ordered list of rules that run for every request matching a route in the project. Each rule has an expression and an action — block, log, or allow.

NoteThe Firewall is currently in preview and rolls out per location. The console shows a “Preview” badge in the sidebar when it’s available.

The Firewall page#

The Firewall tab lists every zone in the project (one per location), with its status, description, rule count, and a 24-hour match sparkline so you can see how busy each rule has been.

console.deploys.app/waf?project=acme
Firewall list showing a single active zone with 3 rules and 431 matches in 24hFirewall list showing a single active zone with 3 rules and 431 matches in 24h
A single firewall zone in gke.cluster-rcf2 — 3 rules, 431 matches in the last day.

Click Manage to view, edit, and reorder the zone’s rules.

How rules work#

Rules evaluate in priority order — lowest priority number first. The first rule whose expression matches the request decides the outcome:

ActionEffect
blockReturn the configured status (default 403) and stop. The request never reaches your deployment.
logRecord a match in metrics and continue evaluating later rules.
allowStop evaluating and forward the request to the deployment, bypassing later rules.

A request that doesn’t match any rule is forwarded normally.

{
  "id": "block-admin",
  "description": "Block external access to /admin",
  "expression": "request.path.startsWith('/admin')",
  "action": "block",
  "status": 403,
  "message": "Forbidden",
  "priority": 10
}

The expression language#

Rule expressions are small boolean expressions over the request. Common references:

  • request.path — the URL path (string).
  • request.methodGET, POST, …
  • request.ip — the client IP as seen by the gateway.
  • request.headers['name'] — a header value (string), lowercased name.
  • request.host — the request hostname.

Operators: ==, !=, &&, ||, !, plus the string helpers .startsWith(s), .endsWith(s), and .contains(s).

request.path.startsWith('/admin')
request.headers['user-agent'].contains('bot')
request.ip == '203.0.113.7'
request.path.endsWith('.php') && !request.headers['x-internal'].contains('yes')

Patterns#

Always allow your own egress IPs. Stick an allow rule with low priority at the top of the zone so good traffic short-circuits the rest of the rules.

priority 10 — allow — request.ip == '203.0.113.7'
priority 50 — block — request.path.startsWith('/admin')
priority 90 — log   — request.headers['user-agent'].contains('bot')

Roll out new blocks safely. Add a rule as log first, watch the matches on the metrics page for a day, then flip it to block once you’ve confirmed it’s catching what you expect (and not what you don’t).

Rate limiting#

Alongside the block/log/allow rules, a zone can carry rate limits — counters that reject (or just watch) traffic arriving faster than a threshold. Limits are independent of the rules: they’re evaluated for every request the zone covers, so a request that passes every rule can still be rejected by a limit.

A limit sorts requests into buckets and rejects a bucket once it exceeds rate requests per window. What defines a bucket is the key:

KeyOne bucket per
ipclient IP (the default)
hostrequest hostname
asnclient network (autonomous system number)
countryclient country
header:<name>value of a request header
cookie:<name>value of a cookie

List several to bucket on the combination — ["ip", "host"] limits each IP per host. With no key the limit defaults to ["ip"].

Limits live on the same zone as the rules. Set them with waf.set, in a limits array next to rules — and, like the rules, waf.set replaces the whole zone, so send the full limits list every time:

"limits": [
  {
    "description": "100 req/min per IP",
    "key": ["ip"],
    "rate": 100,
    "window": "1m"
  },
  {
    "description": "Throttle login to slow credential stuffing",
    "key": ["ip"],
    "rate": 5,
    "window": "1m",
    "filter": "request.path == '/login' && request.method == 'POST'",
    "status": 429,
    "message": "Too many attempts — slow down."
  }
]

Each limit understands:

FieldMeaning
raterequiredMax requests per window per bucket (> 0).
windowrequiredGo duration, 1s1h (e.g. 30s, 1m, 1h).
keyoptionalBucket characteristics (above); default ["ip"].
algorithmoptionalfixed (default) fixed window, or sliding for a smoother rolling window.
modeoptionalenforce (default) rejects; shadow only counts — see below.
statusoptionalResponse status when limited: 429 (default) or 503.
messageoptionalResponse body when limited (default Too Many Requests).
filteroptionalA CEL expression (the same request.* surface as rule expressions) scoping the limit to matching requests; empty means every request. A filter that errors at runtime fails open — the limit is skipped — so a bad filter can’t reject good traffic.

A zone holds up to 20 limits.

Size a limit in shadow mode first. Set "mode": "shadow" and the limit counts matches without rejecting anything. Watch the limited share on the metrics page for a day or two, confirm the threshold only catches abuse, then flip it to enforce. It’s the rate-limit equivalent of rolling out a rule as log before block.

Managed rules (OWASP Core Rule Set)#

Your rules catch exactly what you write them to catch. Managed rules add the generic attack long tail: one toggle enables the OWASP Core Rule Set for the zone — curated signatures for SQL injection, cross-site scripting, path traversal and file inclusion, protocol violations, and scanner/bot fingerprints — maintained and updated by the platform. Keep your own rules for business logic (geo blocks, header gates, path allow-lists); let managed rules handle the attacks nobody writes by hand.

Managed rules evaluate after your rules and before rate limits:

request → your rules (block / log / allow) → managed rules → rate limits → deployment

A request blocked by one of your rules never reaches the managed ruleset, and a request blocked by managed rules is rejected before rate-limit accounting — it never consumes rate budget. An allow rule skips only your remaining rules — managed rules (and rate limits) still evaluate the request. So allow-listing a scanner’s IP does not exempt it from the managed ruleset; if it trips a signature, exclude that rule id (see below) rather than expecting the allow to cover it.

Matching is anomaly-scored rather than first-match: every signature that matches adds to the request’s anomaly score (a critical signature scores 5), and the request is rejected with a 403 once the total reaches the anomaly threshold (default 5). At the defaults, one critical match blocks. Signatures inspect the request line, query string, and headers on every request, and request bodies up to a platform-configured size limit.

Turning it on#

Managed rules live on the zone, next to rules and limits, as a managedRules object on waf.set:

"managedRules": {
  "enabled": true,
  "mode": "enforce",
  "paranoiaLevel": 1,
  "anomalyThreshold": 5,
  "excludedRules": []
}
FieldMeaning
enabledrequiredTurn the managed ruleset on or off for the zone.
modeoptionalenforce (default) rejects over-threshold requests with 403; detect evaluates and records matches but never blocks — see the note below before relying on it.
paranoiaLeveloptional14, default 1. How aggressive the ruleset is — see below.
anomalyThresholdoptional1100, default 5. Total anomaly score at which a request is rejected; each critical match scores 5, so lower = stricter.
excludedRulesoptionalCRS rule ids (911100948999) to disable, up to 50 — false-positive relief, see below.
Warningwaf.set replaces the whole zone — managedRules included. A waf.set that omits the field turns managed rules off and discards the tuning. Scripts must waf.get, edit, and re-set the full zone. To pause enforcement without losing a curated exclusion list, set "enabled": false instead of omitting the field — a disabled block keeps its tuning and round-trips through waf.get intact.

Paranoia levels#

The paranoia level selects how much of the ruleset is active:

LevelCharacter
1Baseline. Covers the classic attacks with minimal false positives — engineered to be safe on ordinary production traffic.
2Adds stricter variants; occasional false positives on unusual-but-legitimate payloads (e.g. raw SQL fragments in form fields).
3Strict. Expect false positives without tuning.
4Paranoid. For high-security targets; substantial tuning required.

Start at level 1 — it’s the default, and at level 1 enforcement is safe for almost all applications. Only raise the level once you have a way to see what would match (per-project match metrics — see the note below) and a workflow to exclude the false positives it surfaces.

NoteMatch visibility is limited in this release. There is no per-project chart of managed-rule matches yet — detect mode records matches, but they are currently visible to platform operators only, so a detect soak tells you nothing on its own. If you hit an unexpected 403 (or want a report of what detect recorded), contact support: operators can list the exact rule ids that fired for your zone. Per-project match metrics on this page’s charts are planned as a follow-up.

Excluding a rule (false positives)#

Every CRS signature has a numeric rule id. When a legitimate request gets blocked, the fix is to exclude the offending rule — not to lower the paranoia level or turn the whole ruleset off:

  1. Reproduce the blocked request and note the time and path.
  2. Get the matching rule id(s) — in this release, ask support for the zone’s match report (see the note above).
  3. Add the id to excludedRules and re-set the zone.
"managedRules": {
  "enabled": true,
  "excludedRules": [942100, 920420]
}

Only detection rules (911100948999) can be excluded; the ruleset’s setup and scoring machinery cannot. Excluding a rule only weakens your zone — nothing you exclude affects other projects.

Availability#

Like the rest of the Firewall, managed rules roll out per location. location.get reports the flag under features.waf.managedRules; in the console, the card shows “Not available in this location” when the zone’s location doesn’t support it yet. waf.set with managedRules enabled on an unsupported location is rejected.

Metrics#

The Firewall metrics page plots matches per (rule, action) over a selectable window — 1h, 6h, 12h, 1d, 7d, 30d — so you can see which rules are hot and catch rule changes that suddenly start matching production traffic.

The same data is available via the API:

curl https://api.deploys.app/waf.metrics \
  -H "Authorization: Bearer $DEPLOYS_TOKEN" \
  -d '{ "project": "acme", "location": "gke.cluster-rcf2",
        "timeRange": "1d" }'

Rate limits have their own series via waf.limitMetrics, returned per (limit, result) where result is allowed or limited. Charting the limited share — limited / (allowed + limited) — is how you size a shadow limit before enforcing it.

curl https://api.deploys.app/waf.limitMetrics \
  -H "Authorization: Bearer $DEPLOYS_TOKEN" \
  -d '{ "project": "acme", "location": "gke.cluster-rcf2",
        "timeRange": "1d" }'