New — Iris AI drafts your firewall, routing & rate-limit rules. Explore AI features →
Back to Blog
securitywafedgeproduct

Ship a Web Application Firewall in One Rule

A Coraza-powered WAF you attach as a single traffic-policy rule — detect by default, block when you're ready, with a curated in-binary baseline and room for your own SecLang.

8 min read

Most web application firewalls are a project. You stand up an appliance or a sidecar, import a rule set, tune it for weeks against false positives, and hope you didn't just start returning 403s to real customers. The Ngris WAF takes a different shape: it's one of the traffic-policy rule types you attach to an endpoint. Turning it on is adding a single rule, and that rule defaults to observing rather than blocking — so the day-one version of "ship a WAF" cannot break your traffic.

One rule, a handful of fields

The rule type is waf. It's one entry in the on_http_request phase of the traffic-policy engine — and it's pinned there. Save a waf rule in any other phase and the API rejects it with waf rule must be in the on_http_request phase, because a firewall that never runs is worse than no firewall at all. Its configuration is small enough to fit in your head:

  • modedetect (log a match, let the request through) or block (deny with a 403). Defaults to detect.
  • rulesetcore, custom, or core+custom. Defaults to core.
  • custom_rules — your own SecLang, appended when the ruleset includes custom.
  • inspect_body — inspect the request body, not just the URI and headers. Off by default.
  • max_body_kb — how much of the body to read for inspection. Defaults to 128, hard-capped at 1024 (1 MiB).

The minimal "ship a WAF" configuration is two fields:

# The waf rule config — attach it to an endpoint, on_http_request phase

{   "mode": "detect",   "ruleset": "core" }

That's it. The WAF now inspects every request to that endpoint against a built-in baseline and logs anything suspicious, without touching a single response.

Detect first, then block

The reason mode defaults to detect is deliberate: enabling the WAF on a live endpoint should never break traffic on the first day. In detect mode a match is logged and the request continues to your origin. You'll see lines like this in the edge logs:

# detect mode: observed, not blocked

WAF: DETECT endpoint=42 rule_id=9000100 action=deny (not blocked — detect mode)

The workflow is the honest one: attach the rule in detect, watch the logs for real matches versus false positives, and only then flip mode to block. In block mode a matched request gets a 403 with the body Request blocked by Web Application Firewall, and because a block is a non-continue action, it short-circuits the rest of the request-phase policy — the request never reaches your origin. This "observe, then enforce" default is the same posture the rest of the platform takes; you're never one save away from a self-inflicted outage.

What the built-in baseline catches

The engine is Coraza, a Go, ModSecurity-compatible WAF that consumes SecLang rules. With ruleset: "core", the rules come from a baseline that's compiled directly into the edge binary — there's no external OWASP Core Rule Set to download, mount, or keep in sync. It's a curated set aimed at the highest-signal attack classes with low false positives:

  • SQL injection — on request arguments and on cookies, via libinjection's @detectSQLi operator.
  • Cross-site scripting — on request arguments, via libinjection's @detectXSS.
  • Path traversal / LFI../ and its encoded variants in the URI and arguments.
  • Sensitive-file access — probes for /etc/passwd, /etc/shadow, /proc/self/environ, .ssh/id_rsa, and friends.
  • OS command injection — shell-metacharacter patterns paired with common binaries (cat, curl, wget, bash, and so on).
  • Log4Shell / JNDI${jndi:ldap://...}-style payloads in the URI, arguments, and headers.
  • Template & expression injection — server-side template, expression, and prototype-pollution markers such as __proto__.
  • Known scanners — bad-bot User-Agents like sqlmap, nikto, nmap, nuclei, and wpscan.

All of the deny rules use status 403. One rule is intentionally softer: an empty User-Agent is treated as a weak signal — it's logged (detect-only) and never blocks on its own, because plenty of legitimate clients omit it. This is a curated baseline, not the full OWASP CRS; the next section is how you get the rest.

Bring your own SecLang

The ruleset field decides what compiles. core is the built-in baseline. custom uses only the rules you supply in custom_rules. core+custom runs the baseline first, then appends yours — the common choice when you want the built-ins plus a few app-specific rules:

# baseline + your own SecLang, still in detect while you tune it

{   "mode": "detect",   "ruleset": "core+custom",   "custom_rules": "SecRule REQUEST_HEADERS:User-Agent \"@rx (?i)internal-scanner\" \"id:100010,phase:1,deny,status:403,msg:'blocked internal scanner'\"" }

Because it's plain SecLang, "I want the full OWASP CRS" has a real answer: paste it into custom_rules. That flexibility comes with a guardrail. SecLang has operators and directives that read files or shell out at request time — @inspectFile executes an external program, and the various *FromFile operators and SecRemoteRules reach outside the request. Those are refused at save time:

# rejected before the rule is ever stored

operators: @inspectFile @pmFromFile / @pmf @ipMatchFromFile / @ipMatchf @rbl @gsbLookup directives: Include SecRemoteRules SecRuleEngine Off SecRuleEngine DetectionOnly SecRuleScript SecAuditLog

The denylist is applied after folding SecLang line-continuations, so a split token like @inspect\File can't slip past. Custom rules are also capped at 65,536 bytes and may not contain NUL. One honest caveat: the API does not link Coraza, so it does not compile-check your SecLang at save time — it validates the mode, ruleset, size, and denylist, but a syntactically broken custom ruleset is accepted and only fails at the edge (see the limitations below).

Inspecting request bodies

By default the WAF inspects the URI and headers only — that's cheaper and covers most of the baseline (query-string arguments are still examined, so GET-based SQLi and XSS attempts are caught without ever reading a body). Set inspect_body: true to also feed the request body to the engine:

# enforce, and inspect up to 256 KB of the body

{   "mode": "block",   "ruleset": "core",   "inspect_body": true,   "max_body_kb": 256 }

Body inspection is bounded and safe for large uploads. Inspection is skipped for GET and HEAD. For everything else, the edge reads a prefix up to max_body_kb (default 128 KB, hard-capped at 1 MiB) into memory — never to disk — feeds that prefix to the engine, and then re-attaches the full, original body to the request before forwarding. Your origin always receives the complete body with a matching Content-Length; the WAF only ever looks at a bounded prefix, it doesn't truncate what you send upstream.

How it works: zero cost when off, one build when on

Two design choices keep the WAF cheap. First, it's presence-gated: the WAF evaluator only runs for endpoints that actually have a waf rule. Endpoints without one pay nothing — there's no interception, no transaction, no cost on the hot path.

Second, the engine is built once and shared. Compiling SecLang is expensive, so the edge memoizes one Coraza engine per unique ruleset-plus-body-limit fingerprint, using a sync.Map and a sync.Once so it's built exactly once even under concurrent traffic. The Coraza engine is concurrent-safe and reused across every request for that ruleset — it is never rebuilt per request. For the common core case the cache key is a fixed string, so the large baseline is never hashed on the hot path; only custom and core+custom rulesets get SHA-256'd, and only over the custom portion. A ruleset that fails to compile is evicted from the cache so a later fix can retry.

Per request, the edge allocates a single Coraza transaction, runs the URI and headers (and optionally the body) through it, and acts on the first interruption. The waf rule sits in the same first-match-wins on_http_request chain as the other traffic-policy rule types, so you can compose it with IP restrictions, rate limits, JWT validation, and the rest.

Honest limitations and tradeoffs

A WAF you can trust is one you understand, so here's what this one does not do:

  • It's not the full OWASP CRS out of the box. The core ruleset is a curated baseline. If you need the complete Core Rule Set, you supply it via custom_rules.
  • It fails open, not closed. If the engine can't build — usually a broken custom ruleset — the edge logs WAF: engine unavailable ... (FAILING OPEN — endpoint is UNPROTECTED) and lets traffic through. That's a deliberate availability choice, but combined with the API not compile-checking your SecLang, a bad custom ruleset can leave an endpoint silently unprotected. Roll custom rules out in detect and watch for that log line.
  • There's no CAPTCHA or JavaScript challenge. The only outcomes are allow or a plain 403. If you want to fingerprint and filter automated clients, that's a separate bot-management rule type — which matches on JA3 TLS fingerprints and is also allow/deny only, with no interactive challenge. Don't reach for the WAF for bot challenges; it doesn't have them.

None of that is a footnote — it's the honest shape of the feature. The WAF is a fast, presence-gated, detect-by-default filter for the highest-signal web attacks, with an escape hatch to your own SecLang when the baseline isn't enough.

Turn it on in detect mode today

Attach one waf rule, watch what it catches, and flip to block when you're ready.

Create a free account →
Ask an AI to summarise this page
Product
API Gateway Secure Tunnels WAF & Firewall Traffic Inspector
AI
Iris AI AI Gateway
More
Solutions Developers Pricing Enterprise Sign in Get Started Free
Iris