Summary
Add opt-in Coraza WAF (OWASP CRS) support to the FrankenPHP variant of this image. The image ships everything needed — a Coraza-enabled binary, the rule sets, and a ready-made Caddy snippet — but the WAF is disabled by default and existing consumers see zero behavior change. A project that wants it (e.g. a Drupal site tired of scanner noise) enables it with minimal work: flip one setting and drop in its own exclusions/firewall config.
Motivation
Public Drupal sites are hammered by scanners probing non-Drupal paths (/wp-login.php, /.env, /.git/config, /xmlrpc.php, /vendor/phpunit/.../eval-stdin.php, ...). These fall through to index.php, causing a full Drupal bootstrap just to render a 404, and pollute watchdog/dblog. The existing Caddyfile hardening matchers catch some of this, but there is no query-string/body inspection and no blocking of well-known scanner paths. Doing the heavy lifting (compiling the module, fetching CRS) in the base image means each downstream project doesn't have to.
Opt-in design
-
Default: off. The shipped /etc/frankenphp/Caddyfile gains one line inside the :80 site, before php_server:
import {$WAF_SNIPPET:/etc/frankenphp/waf/disabled.caddy}
disabled.caddy is an empty (comment-only) file, so out of the box nothing changes. The global block also gets order coraza_waf first, which is harmless when the directive is unused.
-
Enabling is a one-line env var in the project's Dockerfile:
ENV WAF_SNIPPET=/etc/frankenphp/waf/enabled.caddy
enabled.caddy ships in the image and contains the full coraza_waf { ... } block (below).
-
Project-specific work is limited to config: the enabled.caddy block includes /etc/frankenphp/waf/rules/*.conf, so a project COPYs in its own rule files — Drupal-specific CRS exclusions, crs-setup tuning, extra blocked paths — without touching the Caddyfile or rebuilding FrankenPHP.
Current image facts (php8.5-frankenphp-trixie)
- FrankenPHP v1.12.4 / Caddy v2.11.4 / PHP 8.5.7
- Entry:
docker-php-entrypoint → frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile; no entrypoint change is needed.
What the base image ships
1. FrankenPHP binary built with the Coraza module
xcaddy build \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli \
--with github.com/corazawaf/coraza-caddy/v2
- Keep
github.com/dunglas/caddy-cbrotli: the Caddyfile uses encode zstd br gzip, and the br encoder comes from that module. Dropping it breaks the config.
- Cost when disabled: slightly larger binary; no runtime or behavior impact.
2. WAF files under /etc/frankenphp/waf/
-
enabled.caddy:
coraza_waf {
load_owasp_crs
directives `
Include @coraza.conf-recommended
Include @crs-setup.conf.example
Include @owasp_crs/*.conf
Include /etc/frankenphp/waf/rules/*.conf
SecRuleEngine On
`
}
(load_owasp_crs embeds CRS via the coraza-coreruleset Go package; alternatively ship a pinned CRS release and Include its files directly for controlled upgrades.)
-
rules/10-scanner-paths.conf — sensible defaults, deterministic phase-1 hard blocks:
- Deny
*.php requests except Drupal front controllers (index.php, update.php, cron.php, authorize.php, install.php, rebuild.php) — kills wp-login.php, eval-stdin.php, and most scanner 404 noise.
- Deny well-known probe paths:
/.env, /.git, /.aws, /wp-admin, /wp-content, /wp-includes, /xmlrpc.php, /phpmyadmin, /_ignition, /cgi-bin, etc.
3. What an opting-in project provides
- Its own
rules/90-project.conf with CRS exclusions/tuning, e.g.:
- CRS paranoia level 1 false-positives on admin node forms with HTML bodies are likely — exclusions for
/admin/* paths or a raised tx.inbound_anomaly_score_threshold.
SecRequestBodyLimit / SecRequestBodyNoFilesLimit adjusted to expected upload sizes.
- Optionally
SecRuleEngine DetectionOnly during rollout, then On.
Test plan
- Default build: behavior identical to today (regression check).
- With
WAF_SNIPPET enabled:
curl /wp-login.php, /.env, /.git/config → 403 from the WAF (not a Drupal-rendered 404)
- SQLi probe in a query string (e.g.
?id=1 UNION SELECT ...) → 403
- Normal pages, node add/edit with HTML body, file upload → pass through (with project exclusions applied)
encode zstd br gzip still negotiates Brotli (confirms cbrotli survived the custom build)
- Optionally add a custom 403 page via Caddy's
handle_errors
Summary
Add opt-in Coraza WAF (OWASP CRS) support to the FrankenPHP variant of this image. The image ships everything needed — a Coraza-enabled binary, the rule sets, and a ready-made Caddy snippet — but the WAF is disabled by default and existing consumers see zero behavior change. A project that wants it (e.g. a Drupal site tired of scanner noise) enables it with minimal work: flip one setting and drop in its own exclusions/firewall config.
Motivation
Public Drupal sites are hammered by scanners probing non-Drupal paths (
/wp-login.php,/.env,/.git/config,/xmlrpc.php,/vendor/phpunit/.../eval-stdin.php, ...). These fall through toindex.php, causing a full Drupal bootstrap just to render a 404, and pollute watchdog/dblog. The existing Caddyfile hardening matchers catch some of this, but there is no query-string/body inspection and no blocking of well-known scanner paths. Doing the heavy lifting (compiling the module, fetching CRS) in the base image means each downstream project doesn't have to.Opt-in design
Default: off. The shipped
/etc/frankenphp/Caddyfilegains one line inside the:80site, beforephp_server:disabled.caddyis an empty (comment-only) file, so out of the box nothing changes. The global block also getsorder coraza_waf first, which is harmless when the directive is unused.Enabling is a one-line env var in the project's Dockerfile:
ENV WAF_SNIPPET=/etc/frankenphp/waf/enabled.caddyenabled.caddyships in the image and contains the fullcoraza_waf { ... }block (below).Project-specific work is limited to config: the
enabled.caddyblock includes/etc/frankenphp/waf/rules/*.conf, so a project COPYs in its own rule files — Drupal-specific CRS exclusions,crs-setuptuning, extra blocked paths — without touching the Caddyfile or rebuilding FrankenPHP.Current image facts (
php8.5-frankenphp-trixie)docker-php-entrypoint→frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile; no entrypoint change is needed.What the base image ships
1. FrankenPHP binary built with the Coraza module
xcaddy build \ --with github.com/dunglas/frankenphp=./ \ --with github.com/dunglas/frankenphp/caddy=./caddy/ \ --with github.com/dunglas/caddy-cbrotli \ --with github.com/corazawaf/coraza-caddy/v2github.com/dunglas/caddy-cbrotli: the Caddyfile usesencode zstd br gzip, and thebrencoder comes from that module. Dropping it breaks the config.2. WAF files under
/etc/frankenphp/waf/enabled.caddy:(
load_owasp_crsembeds CRS via thecoraza-corerulesetGo package; alternatively ship a pinned CRS release andIncludeits files directly for controlled upgrades.)rules/10-scanner-paths.conf— sensible defaults, deterministic phase-1 hard blocks:*.phprequests except Drupal front controllers (index.php,update.php,cron.php,authorize.php,install.php,rebuild.php) — killswp-login.php,eval-stdin.php, and most scanner 404 noise./.env,/.git,/.aws,/wp-admin,/wp-content,/wp-includes,/xmlrpc.php,/phpmyadmin,/_ignition,/cgi-bin, etc.3. What an opting-in project provides
rules/90-project.confwith CRS exclusions/tuning, e.g.:/admin/*paths or a raisedtx.inbound_anomaly_score_threshold.SecRequestBodyLimit/SecRequestBodyNoFilesLimitadjusted to expected upload sizes.SecRuleEngine DetectionOnlyduring rollout, thenOn.Test plan
WAF_SNIPPETenabled:curl /wp-login.php,/.env,/.git/config→ 403 from the WAF (not a Drupal-rendered 404)?id=1 UNION SELECT ...) → 403encode zstd br gzipstill negotiates Brotli (confirms cbrotli survived the custom build)handle_errors