From 31fb2488679a43677025eaf4ede42ba054de29f0 Mon Sep 17 00:00:00 2001 From: hw Date: Mon, 5 Oct 2026 10:26:46 -0400 Subject: [PATCH 1/2] feat: add opt-in rate limiting to the FrankenPHP image Build caddy-ratelimit into the FrankenPHP binary and add a rate-limit snippet that is off by default, like the WAF. When enabled, each client IP (IPv6 per /64) gets RATE_LIMIT_EVENTS requests per RATE_LIMIT_WINDOW (120 per minute by default). Static assets are not counted. Rate limiting runs before the WAF and basic auth. Add TRUSTED_PROXIES and CLIENT_IP_HEADERS so that, behind a load balancer, clients are told apart by their real IP and do not share one limit. With neither set, Caddy's behaviour is unchanged. Also log the non-standard module versions at build time. --- .github/workflows/docker-buildx.yml | 9 +++- README.md | 41 ++++++++++++++++++- php8/frankenphp-trixie/Caddyfile | 9 ++++ php8/frankenphp-trixie/Dockerfile | 18 +++++--- .../rate-limit/disabled.caddy | 2 + .../rate-limit/enabled.caddy | 19 +++++++++ tests/docker-compose.frankenphp-ratelimit.yml | 11 +++++ tests/verify-frankenphp-optin.sh | 34 +++++++++++++-- tests/verify-frankenphp-waf.sh | 1 + 9 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 php8/frankenphp-trixie/rate-limit/disabled.caddy create mode 100644 php8/frankenphp-trixie/rate-limit/enabled.caddy create mode 100644 tests/docker-compose.frankenphp-ratelimit.yml diff --git a/.github/workflows/docker-buildx.yml b/.github/workflows/docker-buildx.yml index f1bb5e8..61b5e92 100644 --- a/.github/workflows/docker-buildx.yml +++ b/.github/workflows/docker-buildx.yml @@ -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 }}" diff --git a/README.md b/README.md index 664eda1..f0e6e05 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,14 @@ 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:} + client_ip_headers {$CLIENT_IP_HEADERS:X-Forwarded-For} + } } :80 { @@ -234,6 +242,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$ @@ -307,7 +316,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: @@ -368,6 +377,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. `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: diff --git a/php8/frankenphp-trixie/Caddyfile b/php8/frankenphp-trixie/Caddyfile index b9f76a8..020d8c1 100644 --- a/php8/frankenphp-trixie/Caddyfile +++ b/php8/frankenphp-trixie/Caddyfile @@ -5,6 +5,14 @@ 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:} + client_ip_headers {$CLIENT_IP_HEADERS:X-Forwarded-For} + } } :80 { @@ -16,6 +24,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$ diff --git a/php8/frankenphp-trixie/Dockerfile b/php8/frankenphp-trixie/Dockerfile index ee215fa..26d9616 100644 --- a/php8/frankenphp-trixie/Dockerfile +++ b/php8/frankenphp-trixie/Dockerfile @@ -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; \ @@ -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 @@ -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 @@ -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. diff --git a/php8/frankenphp-trixie/rate-limit/disabled.caddy b/php8/frankenphp-trixie/rate-limit/disabled.caddy new file mode 100644 index 0000000..88a4776 --- /dev/null +++ b/php8/frankenphp-trixie/rate-limit/disabled.caddy @@ -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. diff --git a/php8/frankenphp-trixie/rate-limit/enabled.caddy b/php8/frankenphp-trixie/rate-limit/enabled.caddy new file mode 100644 index 0000000..fd6403f --- /dev/null +++ b/php8/frankenphp-trixie/rate-limit/enabled.caddy @@ -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 +} diff --git a/tests/docker-compose.frankenphp-ratelimit.yml b/tests/docker-compose.frankenphp-ratelimit.yml new file mode 100644 index 0000000..5d497a4 --- /dev/null +++ b/tests/docker-compose.frankenphp-ratelimit.yml @@ -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 diff --git a/tests/verify-frankenphp-optin.sh b/tests/verify-frankenphp-optin.sh index f38d7f8..13a5516 100755 --- a/tests/verify-frankenphp-optin.sh +++ b/tests/verify-frankenphp-optin.sh @@ -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 +# Usage: verify-frankenphp-optin.sh default|optin|ratelimit|failclosed # # 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 @@ -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 \ @@ -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}" @@ -84,6 +97,21 @@ 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")" + 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 @@ -95,7 +123,7 @@ failclosed) done ;; *) - echo "Usage: $0 default|optin|failclosed " + echo "Usage: $0 default|optin|ratelimit|failclosed " exit 2 ;; esac diff --git a/tests/verify-frankenphp-waf.sh b/tests/verify-frankenphp-waf.sh index 6566b85..9041fb2 100755 --- a/tests/verify-frankenphp-waf.sh +++ b/tests/verify-frankenphp-waf.sh @@ -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 " From a25a91f92519d3639a069604a7c0636045bc76d3 Mon Sep 17 00:00:00 2001 From: hw Date: Mon, 5 Oct 2026 10:39:26 -0400 Subject: [PATCH 2/2] fix: read X-Forwarded-For strictly from trusted proxies A client could otherwise send its own X-Forwarded-For through a proxy that appends to it, and Caddy would take the forged address on the left as the client IP and so as the rate limit key. --- README.md | 5 ++++- php8/frankenphp-trixie/Caddyfile | 3 +++ tests/verify-frankenphp-optin.sh | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f0e6e05..c6c1fe4 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa 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} } } @@ -401,7 +404,7 @@ services: 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. `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. +**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. diff --git a/php8/frankenphp-trixie/Caddyfile b/php8/frankenphp-trixie/Caddyfile index 020d8c1..ad5a1e8 100644 --- a/php8/frankenphp-trixie/Caddyfile +++ b/php8/frankenphp-trixie/Caddyfile @@ -11,6 +11,9 @@ 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} } } diff --git a/tests/verify-frankenphp-optin.sh b/tests/verify-frankenphp-optin.sh index 13a5516..175a6c7 100755 --- a/tests/verify-frankenphp-optin.sh +++ b/tests/verify-frankenphp-optin.sh @@ -108,6 +108,9 @@ ratelimit) 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)"