diff --git a/assets/img/waf-test-dark.png b/assets/img/waf-test-dark.png new file mode 100644 index 0000000..548f426 Binary files /dev/null and b/assets/img/waf-test-dark.png differ diff --git a/assets/img/waf-test.png b/assets/img/waf-test.png new file mode 100644 index 0000000..842da90 Binary files /dev/null and b/assets/img/waf-test.png differ diff --git a/content/api/conventions.md b/content/api/conventions.md index 9429c2b..5d57f4a 100644 --- a/content/api/conventions.md +++ b/content/api/conventions.md @@ -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 | diff --git a/content/networking/waf.md b/content/networking/waf.md index 2e2b6d0..5a55925 100644 --- a/content/networking/waf.md +++ b/content/networking/waf.md @@ -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. @@ -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') ``` @@ -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') ``` @@ -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 diff --git a/scripts/screenshots/capture.mjs b/scripts/screenshots/capture.mjs index 3e8886b..9f619e4 100644 --- a/scripts/screenshots/capture.mjs +++ b/scripts/screenshots/capture.mjs @@ -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 @@ -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 */ } @@ -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() }