Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/img/waf-test-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/waf-test.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions content/api/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ The big picture. Each row is a fully-qualified API function.
| `domain.purgeCache` | CDN cache purge for a domain |
| `route.list` / `.create` / `.createV2` / `.delete` | Route CRUD |
| `waf.list` / `.get` / `.set` / `.delete` | Firewall zone CRUD (rules + rate limits) |
| `waf.test` | [Dry-run](/networking/waf/#test-rules-dry-run) a zone draft or expression against a sample request |
| `waf.metrics` / `.limitMetrics` | Firewall match counts and rate-limit decisions over time |
| `cache.list` / `.get` / `.set` / `.delete` | Cache-override zone CRUD |
| `cache.metrics` | Cache-override decision counts over time |
Expand Down
103 changes: 100 additions & 3 deletions content/networking/waf.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ references:

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

Expand All @@ -63,7 +63,7 @@ Operators: `==`, `!=`, `&&`, `||`, `!`, plus the string helpers
```text
request.path.startsWith('/admin')
request.headers['user-agent'].contains('bot')
request.ip == '203.0.113.7'
request.remote_ip == '203.0.113.7'
request.path.endsWith('.php') && !request.headers['x-internal'].contains('yes')
```

Expand All @@ -73,7 +73,7 @@ request.path.endsWith('.php') && !request.headers['x-internal'].contains('yes')
at the top of the zone so good traffic short-circuits the rest of the rules.

```text
priority 10 — allow — request.ip == '203.0.113.7'
priority 10 — allow — request.remote_ip == '203.0.113.7'
priority 50 — block — request.path.startsWith('/admin')
priority 90 — log — request.headers['user-agent'].contains('bot')
```
Expand All @@ -82,6 +82,103 @@ priority 90 — log — request.headers['user-agent'].contains('bot')
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).

## Test rules (dry run)

Before saving a rule — or before trusting a whole zone — you can ask the API
what the zone *would* do to a sample request. `waf.test` compiles your
expressions with the same engine the gateway runs, evaluates them against a
synthetic request you describe, and reports every rule's match, the winning
rule, and which rate limits the request would count against. Nothing is
stored and nothing reaches the cluster; it only needs the read-only `waf.get`
permission, and the zone doesn't have to exist yet — testing a draft before
the first `waf.set` is the point.

The console has a **Test** panel on the rule editor, the rate-limit editor,
and the zone manage page.

{{< shot src="/img/waf-test.png" url="console.deploys.app/waf/manage?project=acme" alt="Firewall Test panel showing a blocked dry run: outcome Blocked with status 403, per-rule matched badges, and rate-limit counting notes" caption="Dry-running GET /admin against the zone — block-admin matches and decides the outcome; a blocked request is never counted against the rate limits." >}}

There are two ways to call it:

| Mode | Send | Good for |
|---|---|---|
| **Expression** | `expression` — one CEL expression | Checking a single rule or limit filter while you write it |
| **Zone draft** | `rules` + `limits` — the same payload as `waf.set` | Dry-running a whole zone before (or after) saving it |

Send exactly one of the two. In expression mode the expression runs as a
single `log` rule with id `expression` — and since `log` never terminates,
the `outcome` is always `pass`; whether the expression matched is
`rules[0].matched`.

```bash
curl https://api.deploys.app/waf.test \
-H "Authorization: Bearer $DEPLOYS_TOKEN" \
-d '{ "project": "acme", "location": "gke.cluster-rcf2",
"expression": "request.country == \"TH\" && request.path.startsWith(\"/admin\")",
"request": { "method": "GET", "path": "/admin",
"host": "app.example.com",
"ip": "203.0.113.7", "country": "TH" } }'
```

The sample `request` describes the synthetic request: `method` (default
`GET`), `path` (required), `query`, `host`, `scheme` (default `https`),
`headers` and `cookies` maps, `ip`, `country`, and `asn`.

{{< callout type="note" >}}
`country` and `asn` are **simulation inputs, supplied by you** — the API does
no GeoIP lookup. In production the gateway resolves them from the client IP
at the edge; in a dry run, whatever you put in `country`/`asn` is what
`request.country`/`request.asn` will contain (leave them empty/zero to
simulate an unresolved lookup).
{{< /callout >}}

The result reports:

| Field | Meaning |
|---|---|
| `outcome` | `pass`, `allow`, or `block` — the zone's terminal decision. |
| `winningRuleId` | The rule that decided the outcome (empty on `pass`). |
| `status` / `message` | The block response (403 / `Forbidden` by default); only set on `block`. |
| `rules[]` | Every rule in evaluation order, with `matched`, `evaluated`, `terminal`, and a per-rule `error`. |
| `limits[]` | Every rate limit, with `filterMatched` and `counted` (see below). |
| `valid` | `false` when any expression failed to compile — the same draft would be rejected by `waf.set`. |

Every rule is evaluated independently, so `matched` is reported even for
rules *after* the winning allow/block; those come back with
`evaluated: false` because the real engine short-circuits there. A rule that
errors at runtime gets its `error` set and is skipped in the decision walk —
the same fail-open behavior as production. A rule that fails to *compile* is
likewise reported per-rule and skipped, but only in the dry run: production
never runs a compile-broken rule at all, because `waf.set` rejects the save
(that's what `valid: false` tells you). Either way the rest of the zone keeps
evaluating, so one broken expression doesn't hide the results for everything
else.

For each rate limit, `filterMatched` means the limit's filter selects this
request (always true for a limit with no filter), and `counted` means the
request would actually be counted against the limit — a request blocked by a
rule never reaches the rate limiter, so it burns no rate budget. Neither
means the request would be *limited*: that depends on live counters, which a
dry run can't know.

{{< callout type="warning" >}}
The dry run simulates **your zone, assuming the request reaches it**. A few
things it cannot reproduce:

- The platform's global baseline rules and the managed WAF layer run before
your zone and are not simulated — either can block a real request before
your rules ever see it.
- The synthetic request has no body: `request.body` is always `""` and
`request.content_length` is always `0`.
- `request.proto` is always `"HTTP/1.1"`; production HTTP/2 traffic reports
`"HTTP/2.0"`, so expressions on `request.proto` can't be faithfully
simulated.
- The dry run budgets its evaluation time per expression, while production
budgets one small window for the whole ruleset walk — a very heavy zone
(many complex regexes) can fully match in a dry run yet time out mid-walk
in production, fail-open skipping the remaining rules.
{{< /callout >}}

## Rate limiting

Alongside the block/log/allow rules, a zone can carry **rate limits** — counters
Expand Down
44 changes: 43 additions & 1 deletion scripts/screenshots/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,47 @@ async function shotDeployForm (ctx, suffix) {
}
}

/**
* The firewall Test panel (waf.test dry run) only shows results after an
* interaction: expand the collapsed panel on the manage page, describe a
* sample request that the seed zone's block-admin rule matches, run it, and
* capture the outcome banner + per-rule/per-limit result rows.
*
* The mock's dry-run evaluator only understands the visual builder's own
* double-quoted CEL forms (anything else silently reports "not matched"), so
* a "Blocked" outcome here requires the console seed zone's expressions to be
* in that form — e.g. `request.path.startsWith("/admin")`, not `'/admin'`.
* @param {import('@playwright/test').BrowserContext} ctx
* @param {string} suffix
*/
async function shotWafTest (ctx, suffix) {
const page = await ctx.newPage()
try {
await page.setViewportSize({ width: 1440, height: 1150 })
await page.goto(BASE + `/waf/manage?${P}&${LOC}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
await page.locator('.test-toggle').click()
await page.fill('#waf-test-path', '/admin')
await page.fill('#waf-test-host', 'app.example.com')
await page.fill('#waf-test-ip', '198.51.100.24')
await page.getByRole('button', { name: 'Run test' }).click()
await page.locator('.outcome-banner').waitFor({ timeout: 10000 })
await page.waitForTimeout(400)
// Bring the whole panel (toggle through limit results) into the frame.
await page.locator('.test-toggle').evaluate((el) => {
el.closest('.panel')?.scrollIntoView({ block: 'start' })
window.scrollBy(0, -16)
})
await page.waitForTimeout(200)
await page.screenshot({ path: `${OUT}/waf-test${suffix}.png` })
console.log(`ok waf-test${suffix} (dry run)`)
} catch (e) {
console.log(`FAIL waf-test${suffix}`, String(e).split('\n')[0])
} finally {
await page.close()
}
}

// Warm up every route once before the timed captures. The dev server compiles
// routes on first visit; without this the first (light) pass pays that cost
// inside each capture's timeout — most visibly on the deploy form, whose
Expand All @@ -113,7 +154,7 @@ async function shotDeployForm (ctx, suffix) {
{
const warm = await browser.newContext()
const page = await warm.newPage()
for (const [, path] of [...screens, ['deploy-form', `/deployment/deploy?${P}`]]) {
for (const [, path] of [...screens, ['deploy-form', `/deployment/deploy?${P}`], ['waf-test', `/waf/manage?${P}&${LOC}`]]) {
try {
await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 })
} catch { /* a slow warm-up visit is fine; the timed pass re-navigates */ }
Expand All @@ -136,6 +177,7 @@ for (const theme of /** @type {const} */ (['light', 'dark'])) {
console.log(`ok ${name}${suffix}`)
}
await shotDeployForm(ctx, suffix)
await shotWafTest(ctx, suffix)
await ctx.close()
}

Expand Down