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..c6c1fe4 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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$ @@ -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: @@ -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: diff --git a/php8/frankenphp-trixie/Caddyfile b/php8/frankenphp-trixie/Caddyfile index b9f76a8..ad5a1e8 100644 --- a/php8/frankenphp-trixie/Caddyfile +++ b/php8/frankenphp-trixie/Caddyfile @@ -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:} + # 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 { @@ -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$ 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..175a6c7 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,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 @@ -95,7 +126,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 "