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
9 changes: 8 additions & 1 deletion .github/workflows/docker-buildx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,14 @@ jobs:
-f tests/docker-compose.frankenphp-waf-detection.yml up -d --force-recreate
./tests/verify-frankenphp-waf.sh detection

- name: Verify FrankenPHP binary (Coraza, Brotli, capability)
- name: Verify FrankenPHP rate limiting
if: matrix.variant == 'frankenphp-trixie'
run: |
docker compose -f tests/docker-compose.yml \
-f tests/docker-compose.frankenphp-ratelimit.yml up -d --force-recreate
./tests/verify-frankenphp-optin.sh ratelimit

- name: Verify FrankenPHP binary (Coraza, Brotli, rate limit, capability)
if: matrix.variant == 'frankenphp-trixie'
run: ./tests/verify-frankenphp-waf.sh capability "drupal-test:${{ matrix.php_version }}-${{ matrix.variant }}"

Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,17 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa
order php_server before file_server
# Harmless unless the WAF snippet is enabled
order coraza_waf first
# Rate limiting runs before the WAF and basic auth, so floods and password
# guessing are cut off cheaply. Harmless unless its snippet is enabled.
order rate_limit before coraza_waf
servers {
# Proxies whose client IP headers are believed. None by default.
trusted_proxies static {$TRUSTED_PROXIES:}
# Read X-Forwarded-For from the right, skipping trusted proxies, so a
# client cannot pick its own address by sending the header itself.
trusted_proxies_strict
client_ip_headers {$CLIENT_IP_HEADERS:X-Forwarded-For}
}
}

:80 {
Expand All @@ -234,6 +245,7 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa
import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy}
import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy}
import {$WAF_SNIPPET:/etc/frankenphp/waf/disabled.caddy}
import {$RATE_LIMIT_SNIPPET:/etc/frankenphp/rate-limit/disabled.caddy}

# Block hidden PHP files
@hiddenPhp path_regexp \..*/.*.php$
Expand Down Expand Up @@ -307,7 +319,7 @@ If you mount your own Caddyfile, it only gets these features if it contains the

#### Web application firewall (Coraza)

The FrankenPHP binary in this image is built with the [Coraza](https://coraza.io/) WAF module for Caddy and ships the [OWASP Core Rule Set](https://coreruleset.org/) (CRS, embedded in the binary), plus a set of default blocks for scanner traffic. The WAF is **off by default**: the Caddyfile imports an empty snippet and nothing changes for you until you opt in. The binary is otherwise the upstream one (same modules, including Brotli, Mercure and Vulcain) and keeps its `cap_net_bind_service` capability.
The FrankenPHP binary in this image is built with the [Coraza](https://coraza.io/) WAF module for Caddy and ships the [OWASP Core Rule Set](https://coreruleset.org/) (CRS, embedded in the binary), plus a set of default blocks for scanner traffic. The WAF is **off by default**: the Caddyfile imports an empty snippet and nothing changes for you until you opt in. The binary also has the rate limit module (see below) and is otherwise the upstream one (same modules, including Brotli, Mercure and Vulcain). It keeps its `cap_net_bind_service` capability.

Turn it on with one environment variable, for example in your Dockerfile or compose file:

Expand Down Expand Up @@ -368,6 +380,36 @@ Delete that line (the engine is `On` by default) when the log is clean.

As with the other snippets, a Caddyfile you mount yourself needs the `import` line and `order coraza_waf first` in the global block to get the WAF.

#### Rate limiting

The binary includes [caddy-ratelimit](https://github.com/mholt/caddy-ratelimit), and the Caddyfile can limit how many requests each client makes. It is **off by default**. Turn it on with one environment variable:

```yaml
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
environment:
RATE_LIMIT_SNIPPET: /etc/frankenphp/rate-limit/enabled.caddy
# Behind a load balancer or CDN, trust it so each visitor gets their own limit
TRUSTED_PROXIES: private_ranges
```

| Variable | Default | Purpose |
| --- | --- | --- |
| `RATE_LIMIT_SNIPPET` | `/etc/frankenphp/rate-limit/disabled.caddy` | Caddy snippet imported for rate limiting. Set to `/etc/frankenphp/rate-limit/enabled.caddy` to enable it. |
| `RATE_LIMIT_EVENTS` | `120` | Requests a client may make in the window. |
| `RATE_LIMIT_WINDOW` | `1m` | The sliding window, as a Caddy duration (`30s`, `1m`, `1h`). |
| `TRUSTED_PROXIES` | unset (none) | Space-separated IP ranges of proxies in front of the site, or `private_ranges`. Their client IP headers are believed. |
| `CLIENT_IP_HEADERS` | `X-Forwarded-For` | Space-separated headers that carry the client IP, read only from trusted proxies. For example `CF-Connecting-IP X-Forwarded-For` for Cloudflare. |

A client that goes over the limit gets a `429` with a `Retry-After` header, and the container log has a `rate limit exceeded` line with the client's address. Clients are told apart by IP address. IPv6 addresses share a limit per `/64`, because one host usually has a whole `/64`. Static assets (CSS, JS, images and fonts) are not counted, so a page load uses one request of the budget, plus any AJAX calls it makes. Rate limiting runs before the WAF and basic auth, so it also slows down password guessing.

**Behind a proxy, set `TRUSTED_PROXIES`.** Without it every request seems to come from the load balancer, so all visitors share one limit and a busy site starts answering `429`. Only list proxies you control. A trusted range can claim any client IP, so trusting too much lets anyone pick their own limit. The header is read from the right ([`trusted_proxies_strict`](https://caddyserver.com/docs/caddyfile/options#trusted-proxies-strict)), skipping trusted proxies, so an address a client adds to `X-Forwarded-For` itself is ignored when your proxy appends to the header. `TRUSTED_PROXIES` sets Caddy's [`trusted_proxies`](https://caddyserver.com/docs/caddyfile/options#trusted-proxies), so it also changes the client IP in Caddy's logs. PHP's `REMOTE_ADDR` is not affected. Configure Drupal's `reverse_proxy` settings for that.

**Choosing a limit.** The default of 120 requests a minute (excluding static assets) is a starting point. Many editors behind one office NAT share an address, and some admin pages make many AJAX requests. Raise the limit if you see legitimate `429`s in the log. The counts are kept in memory, per container. With several replicas, each one counts separately, and a restart resets them.

As with the other snippets, a Caddyfile you mount yourself needs the `import` line, and `order rate_limit before coraza_waf` (or another `order` for `rate_limit`) in the global block, to get rate limiting.

#### Custom Caddyfile

To customize the Caddy configuration, mount your own Caddyfile:
Expand Down
12 changes: 12 additions & 0 deletions php8/frankenphp-trixie/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
order php_server before file_server
# Harmless unless the WAF snippet is enabled
order coraza_waf first
# Rate limiting runs before the WAF and basic auth, so floods and password
# guessing are cut off cheaply. Harmless unless its snippet is enabled.
order rate_limit before coraza_waf
servers {
# Proxies whose client IP headers are believed. None by default.
trusted_proxies static {$TRUSTED_PROXIES:}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Read X-Forwarded-For from the right, skipping trusted proxies, so a
# client cannot pick its own address by sending the header itself.
trusted_proxies_strict
client_ip_headers {$CLIENT_IP_HEADERS:X-Forwarded-For}
}
}

:80 {
Expand All @@ -16,6 +27,7 @@
import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy}
import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy}
import {$WAF_SNIPPET:/etc/frankenphp/waf/disabled.caddy}
import {$RATE_LIMIT_SNIPPET:/etc/frankenphp/rate-limit/disabled.caddy}

# Block hidden PHP files
@hiddenPhp path_regexp \..*/.*.php$
Expand Down
18 changes: 13 additions & 5 deletions php8/frankenphp-trixie/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy

# Pinned so the build is reproducible; bump it deliberately.
ARG CORAZA_CADDY_VERSION=v2.6.1
# A commit on master: the only release, v0.1.0, predates ipv6_prefix and the
# metrics fixes.
ARG CADDY_RATELIMIT_VERSION=v0.1.1-0.20260612195517-5625512f24f6

# CGO must be enabled to build FrankenPHP. XCADDY_SETCAP keeps the
# cap_net_bind_service capability the upstream binary has. The build tags are
# the ones upstream uses. The module set matches the upstream binary (cbrotli
# for "encode br", Mercure, Vulcain) plus Coraza. Caddy and those modules are
# pinned to the versions in FrankenPHP's own go.mod: xcaddy would otherwise
# take the latest releases, which can need a newer Go than the builder has.
# for "encode br", Mercure, Vulcain) plus Coraza and caddy-ratelimit. Caddy and
# the upstream modules are pinned to the versions in FrankenPHP's own go.mod:
# xcaddy would otherwise take the latest releases, which can need a newer Go
# than the builder has.
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
Expand All @@ -35,10 +39,13 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
--with "github.com/dunglas/mercure/caddy@$(ver github.com/dunglas/mercure/caddy)" \
--with "github.com/dunglas/vulcain/caddy@$(ver github.com/dunglas/vulcain/caddy)" \
--with "github.com/corazawaf/coraza-caddy/v2@${CORAZA_CADDY_VERSION}" \
--with "github.com/mholt/caddy-ratelimit@${CADDY_RATELIMIT_VERSION}" \
; \
frankenphp version; \
frankenphp list-modules --versions --skip-standard; \
frankenphp list-modules | grep -qx 'http.handlers.waf'; \
frankenphp list-modules | grep -qx 'http.encoders.br'
frankenphp list-modules | grep -qx 'http.encoders.br'; \
frankenphp list-modules | grep -qx 'http.handlers.rate_limit'

# Stage 2: the runtime image
FROM dunglas/frankenphp:php${PHP_VERSION}-trixie
Expand All @@ -50,7 +57,7 @@ LABEL org.opencontainers.image.source="https://github.com/hussainweb/docker-drup
org.opencontainers.image.description="Drupal-ready PHP ${PHP_VERSION} runtime on Debian Trixie with FrankenPHP (Caddy + PHP)" \
org.opencontainers.image.version="${PHP_VERSION}"

# Replace the upstream binary with the one that includes the Coraza module
# Replace the upstream binary with the one that includes Coraza and rate limiting
COPY --from=frankenphp-builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp

# install the PHP extensions we need
Expand Down Expand Up @@ -174,6 +181,7 @@ COPY Caddyfile /etc/frankenphp/Caddyfile
COPY noindex/ /etc/frankenphp/noindex/
COPY basic-auth/ /etc/frankenphp/basic-auth/
COPY waf/ /etc/frankenphp/waf/
COPY rate-limit/ /etc/frankenphp/rate-limit/

# Prepares basic auth from environment variables, then runs the upstream
# entrypoint. Setting ENTRYPOINT resets CMD, so re-declare the upstream one.
Expand Down
2 changes: 2 additions & 0 deletions php8/frankenphp-trixie/rate-limit/disabled.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Rate limiting is disabled (default). Nothing to configure.
# Set RATE_LIMIT_SNIPPET=/etc/frankenphp/rate-limit/enabled.caddy to enable it.
19 changes: 19 additions & 0 deletions php8/frankenphp-trixie/rate-limit/enabled.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Opt-in: per-client rate limiting (caddy-ratelimit).
# Enable with RATE_LIMIT_SNIPPET=/etc/frankenphp/rate-limit/enabled.caddy
#
# Clients are told apart by {client_ip}, which is the connecting address
# unless TRUSTED_PROXIES is set. IPv6 clients share a limit per /64, since
# one host usually has a whole /64. Static assets are not counted, so a page
# with many images or scripts uses one request of the budget.
rate_limit {
zone drupal_base {
match {
not path *.avif *.css *.eot *.gif *.gz *.ico *.jpg *.jpeg *.js *.otf *.pdf *.png *.svg *.ttf *.webp *.woff *.woff2
}
key {client_ip}
events {$RATE_LIMIT_EVENTS:120}
window {$RATE_LIMIT_WINDOW:1m}
ipv6_prefix 64
}
log_key
}
11 changes: 11 additions & 0 deletions tests/docker-compose.frankenphp-ratelimit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Override for the FrankenPHP variant: turns on the opt-in rate limit with a
# small budget, and trusts the Docker network so the test can set the client
# with X-Forwarded-For.
# Layered over tests/docker-compose.yml by the CI step that runs tests/verify-frankenphp-optin.sh ratelimit.
services:
drupal:
environment:
RATE_LIMIT_SNIPPET: /etc/frankenphp/rate-limit/enabled.caddy
RATE_LIMIT_EVENTS: 5
RATE_LIMIT_WINDOW: 1m
TRUSTED_PROXIES: private_ranges
37 changes: 34 additions & 3 deletions tests/verify-frankenphp-optin.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#!/bin/bash
# Checks the opt-in FrankenPHP features (basic auth, noindex, PHP memory limit).
# Checks the opt-in FrankenPHP features (basic auth, noindex, PHP memory limit,
# rate limiting).
#
# Usage: verify-frankenphp-optin.sh default|optin|failclosed <image>
# Usage: verify-frankenphp-optin.sh default|optin|ratelimit|failclosed <image>
#
# default run against the site started with none of the variables set
# optin run against the site started with docker-compose.frankenphp-optin.yml
# (needs BASIC_AUTH_PASSWORD in the environment)
# ratelimit run against the site started with docker-compose.frankenphp-ratelimit.yml
# failclosed run the image directly with incomplete basic auth settings
set -u

Expand Down Expand Up @@ -41,6 +43,12 @@ wait_for_web() {
exit 1
}

status() { # path, extra curl args
local path=$1
shift
curl -s -o /dev/null -w '%{http_code}' "$@" "$BASE_URL$path"
}

# memory_limit as the web server sees it (a request), not the CLI.
web_memory_limit() { # extra curl args
docker compose exec -T "$SERVICE" sh -c \
Expand All @@ -58,6 +66,11 @@ default)
check_contains "/robots.txt is Drupal's own" "Disallow: /core/" "$robots"
check "web memory_limit is PHP's default" 128M "$(web_memory_limit)"
check "CLI memory_limit" -1 "$(docker compose exec -T "$SERVICE" php -r 'echo ini_get("memory_limit");')"
limited=0
for _ in $(seq 1 20); do
[ "$(status /user/login)" = 429 ] && limited=1
done
check "20 requests are not rate limited" 0 "$limited"
;;
optin)
: "${BASIC_AUTH_PASSWORD:?BASIC_AUTH_PASSWORD must be set}"
Expand All @@ -84,6 +97,24 @@ optin)
pass "BASIC_AUTH_PASSWORD is unset in the server process"
fi
;;
ratelimit)
wait_for_web
# The Docker network is trusted, so X-Forwarded-For picks the client. Each
# check uses its own address, apart from the one wait_for_web used.
a=(-H "X-Forwarded-For: 198.51.100.1")
b=(-H "X-Forwarded-For: 198.51.100.2")
for i in $(seq 1 5); do
check "request $i of 5 is allowed" 200 "$(status /user/login "${a[@]}")"
done
check "request 6 is rate limited" 429 "$(status /user/login "${a[@]}")"
check_contains "429 carries Retry-After" "Retry-After:" "$(curl -si "${a[@]}" "$BASE_URL/user/login")"
# Strict parsing reads X-Forwarded-For from the right, so a forged
# address the client puts in front does not give it a new limit.
check "a forged X-Forwarded-For prefix is ignored" 429 "$(status /user/login -H "X-Forwarded-For: 192.0.2.1, 198.51.100.1")"
check "static assets are not counted" 200 "$(status /core/misc/drupal.js "${a[@]}")"
check "another client is allowed" 200 "$(status /user/login "${b[@]}")"
check_contains "the log has the client" '"key":"198.51.100.1"' "$(docker compose logs "$SERVICE" 2>&1)"
;;
failclosed)
[ -n "$IMAGE" ] || { echo "image required"; exit 2; }
enabled=/etc/frankenphp/basic-auth/enabled.caddy
Expand All @@ -95,7 +126,7 @@ failclosed)
done
;;
*)
echo "Usage: $0 default|optin|failclosed <image>"
echo "Usage: $0 default|optin|ratelimit|failclosed <image>"
exit 2
;;
esac
Expand Down
1 change: 1 addition & 0 deletions tests/verify-frankenphp-waf.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ capability)
mods=$(docker run --rm --entrypoint frankenphp "$IMAGE" list-modules 2>&1)
check_contains "WAF module is in the binary" "http.handlers.waf" "$mods"
check_contains "Brotli encoder is in the binary" "http.encoders.br" "$mods"
check_contains "rate limit module is in the binary" "http.handlers.rate_limit" "$mods"
;;
*)
echo "Usage: $0 default|enabled|detection|capability <image>"
Expand Down