Skip to content
HoginHogin
Go back

WAF on a budget: protecting your app with Coraza + OWASP CRS, no enterprise price tag

9 мин чтения

A WAF used to be a purchase: a cloud service billed by traffic, or an appliance with “contact us for pricing”. Meanwhile the classic self-hosted option — ModSecurity — grew a reputation for fragility: C dependencies, floating issues in the v3 nginx connector, leisurely development. In 2026 there is a working alternative without those trade-offs: Coraza, a WAF engine written in Go that understands the same SecLang and OWASP Core Rule Set v4 — and fits into a container of a few dozen megabytes. This post sits outside the Wazuh series, but next door to it: at the end we’ll feed the WAF’s blocks into the same SIEM.

Table of contents

Open Table of contents

What a WAF actually blocks — and what it doesn’t

A WAF is a reverse proxy with signature-based HTTP analysis: it parses the path, headers, query string, and body, runs them through a rule set, and decides — pass or reject. OWASP CRS, the de facto standard rule set, covers the typical arsenal:

What a WAF does not solve — worth stating honestly, or it becomes a false sense of security:

The WAF in the traffic path

A note on TLS: in our design the WAF is the reverse proxy that terminates encryption, so the rules see requests in the clear. The corollary is simple: don’t place it behind a TLS-passthrough load balancer — there it would only see encrypted noise.

A WAF is a filter for typical attacks in front of the app — not a replacement for audits, dependency scanning, and sane access control.

Backstory: ModSecurity and SecLang

For a decade, “self-hosted WAF” meant exactly one thing: ModSecurity. Under Apache the v2 module is stable, but it’s legacy code — and a separate web server just for the WAF; under nginx the v3 connector (libmodsecurity) has long been known for floating segfaults and memory leaks. Development slowed down, while the ecosystem around it did not: the OWASP CRS rules live their own life and need a compatible engine.

The valuable thing ModSecurity left behind is not code but SecLang: a declarative rule language (SecRule, phases, transformations, actions) in which tens of thousands of rules are written — including all of CRS. So the successors compete not on “their own format” but on compatibility with it.

Coraza: the engine and its frontends

Coraza is a WAF engine written in Go from scratch, with no C dependencies. It understands SecLang — including parts of the v3 syntax that ModSecurity users waited years for — and officially supports OWASP CRS v4. In ngrok’s benchmark running CRS on both engines, Coraza came out 20–40% faster than classic ModSecurity under the same load. As a library it embeds anywhere, and ready-made frontends cover the mainstream proxies:

Coraza: one engine, many frontends

The practical consequence: the WAF stops being a separate product. It’s a layer inside the proxy you already run — upgradable, testable, and asking for no license key.

Paranoia levels: coverage vs false positives

CRS tunes its aggressiveness with a single number — the paranoia level, 1 through 4. Each level enables additional rule groups: what slipped through at PL1 matches and gets blocked at PL2. The cost is predictable — more false positives: legitimate JSON fields, product searches, code snippets in tickets start tripping SQLi and XSS rules.

The paranoia level ladder

The tuning loop is the same regardless of level:

Levels 3 and 4 exist but are rare in production: they’re built for environments where noise is an acceptable price for coverage — PCI DSS monitoring feeds, or honeypots with barely any legitimate traffic. For an ordinary application the sensible ceiling is PL2 with well-groomed exclusions.

Surgical exclusions are the discipline that separates a working WAF from one switched off a month later because “it breaks everything”.

ModSecurity vs Coraza

ModSecurityCoraza
Language and dependenciesC, libmodsecurityGo, no C dependencies
Rule languageSecLang (native)SecLang-compatible
IntegrationsApache v2 module, nginx v3 connectorCaddy, Traefik, Envoy (WASM), HAProxy, library
OWASP CRS v4supportedsupported
Speed on CRSbaseline+20–40% (ngrok benchmark)
Container imagehundreds of MBtens of MB

What you need to put a WAF in front of your app

You need Docker and any application that answers HTTP. The demo layout:

waf-demo/
├── compose.yaml
└── waf/
    ├── Dockerfile
    ├── Caddyfile
    ├── coraza.conf
    └── crs-tuning.conf

waf/Dockerfile — Caddy built with the Coraza module, plus a clone of CRS:

FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/corazawaf/coraza-caddy
RUN git clone --depth 1 https://github.com/coreruleset/owasp-modsecurity-crs.git /owasp-crs

FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY --from=builder /owasp-crs /etc/caddy/owasp-crs
RUN cp /etc/caddy/owasp-crs/crs-setup.conf.example /etc/caddy/owasp-crs/crs-setup.conf
COPY Caddyfile /etc/caddy/Caddyfile
COPY coraza.conf crs-tuning.conf /etc/caddy/

waf/Caddyfile — the WAF as the first directive, the upstream set by a single env var:

{
	order coraza_waf first
}

waf {
	load_owasp_crs /etc/caddy/owasp-crs
	include /etc/caddy/coraza.conf
}

:8080 {
	coraza_waf waf
	reverse_proxy {$UPSTREAM}
}

waf/coraza.conf — turn the engine on, wire in CRS, enable a JSON audit log of blocks:

SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecRequestBodyLimitAction Reject

# tuning BEFORE CRS: paranoia level and application variables
Include /etc/caddy/crs-tuning.conf

# OWASP CRS v4: setup + rules
Include /etc/caddy/owasp-crs/crs-setup.conf
Include /etc/caddy/owasp-crs/rules/*.conf

# exclusions AFTER CRS — they act on already-loaded rules
# SecRuleRemoveTargetById 942100 "ARGS:q"

# audit log of blocks: one JSON line per transaction
SecAuditEngine RelevantOnly
SecAuditLog /dev/stdout
SecAuditLogFormat JSON

waf/crs-tuning.conf:

# 1 — default, 2 — more aggressive (raise after tuning exclusions)
SecAction "id:110000,phase:1,pass,t:none,nolog,setvar:tx.paranoia_level=1"

One nuance about include order: SecRuleRemove* and SecRuleUpdateTarget* act on rules that are already loaded, so exclusions are written after the CRS Include; variables like paranoia_level go before, so the CRS setup picks them up at startup.

compose.yaml:

services:
  app:
    image: traefik/whoami
    expose:
      - "80"

  waf:
    build: ./waf
    environment:
      UPSTREAM: app:80
    ports:
      - "8080:8080"
    depends_on:
      - app

Start it with docker compose up -d --build. Swap traefik/whoami for your image and UPSTREAM for your app’s address — nothing else changes.

How to verify it works

A clean request passes; SQLi and XSS are dropped on the fly:

curl -i http://localhost:8080/                          # 200 — legitimate request
curl -i "http://localhost:8080/?id=1%20OR%201%3D1--"     # 403 — SQLi, rule 942100
curl -i "http://localhost:8080/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E"  # 403 — XSS, 941100

The container log holds JSON transactions with the rule ID that fired:

docker compose logs waf | grep -E '942100|941100'

Each transaction carries client_ip, the URI, the response code, and the list of triggered rules with their messages — enough to dissect any block without guessing. And the 200 on the clean request will show whoami’s headers: proof the traffic actually reached the app and wasn’t synthesized by the proxy.

The budget is checked the same way: the container fits in ~150–200 MB of RAM — the WAF squeezes into the same $10/month VPS that already runs your application.

Where the blocks go

Every 403 is a signal: someone is probing your app. Waiting until a human notices them in the logs is optional — in the Wazuh series we already built the “JSON log → Fluent Bit → manager’s syslog listener” pipeline for Kubernetes audit. Here it repeats verbatim: Fluent Bit tails the WAF container log and ships the lines over syslog to 514/tcp.

[INPUT]
    Name   tail
    Path   /var/log/containers/*waf*.log
    Parser docker

[OUTPUT]
    Name   syslog
    Host   WAZUH_MANAGER_IP
    Port   514
    Format rfc5424

On the manager, the JSON decoder unfolds the transaction and a rule hooks the response code — the numbering continues the series:

<rule id="100230" level="6">
  <decoded_as>json</decoded_as>
  <field name="transaction.response.http_code">^403$</field>
  <description>waf: request blocked by Coraza — check transaction.messages for rule id</description>
</rule>

From there it’s familiar machinery: levels, an active response to repeat offenders, a dashboard. Hosts, cluster audit, and now WAF blocks converge on one SIEM.

Bottom line

A WAF “on a budget” is not a five-thousand-dollars-a-month enterprise appliance — it’s one container between the user and the application: Caddy with the Coraza module, OWASP CRS v4, and an hour of careful exclusion tuning. It covers 80% of typical web attacks — SQLi, XSS, traversal, scanners — for ~150–200 MB of RAM and $0 in licenses, honestly making no claim on business-logic bugs or stolen sessions. And the blocks, in turn, are ready-made SIEM events: the attack was repelled, but the residue remains — and it’s in your dashboard.


Share this post:

Next Post
Falco in Kubernetes: syscall-level runtime protection, unified with Wazuh SIEM (part 6/6)