From 1fa648c123201305fed30aa21e0c48e6b412a7cf Mon Sep 17 00:00:00 2001 From: Vidar Langseid Date: Fri, 14 Aug 2026 09:30:47 +0200 Subject: [PATCH 1/5] IBX-11181: Filtered client-supplied X-Forwarded-* headers in Varnish VCL --- docs/varnish/README.md | 14 ++++++++++++++ docs/varnish/vcl/parameters.vcl | 13 +++++++++++++ docs/varnish/vcl/varnish5.vcl | 23 +++++++++++++++++++++++ docs/varnish/vcl/varnish6.vcl | 23 +++++++++++++++++++++++ docs/varnish/vcl/varnish7.vcl | 23 +++++++++++++++++++++++ 5 files changed, 96 insertions(+) diff --git a/docs/varnish/README.md b/docs/varnish/README.md index af35bef..703ca57 100644 --- a/docs/varnish/README.md +++ b/docs/varnish/README.md @@ -13,6 +13,20 @@ For Varnish to work properly with eZ, you'll need to use the provided configurat * [eZ Platform 2.5LTs optimized Varnish 5.1+ VCL](vcl/varnish5.vcl) +Trusted proxies +--------------- +The VCL strips the `X-Forwarded-*` and `Forwarded` request headers from every client that is not +listed in the `trusted_proxies` ACL in [parameters.vcl](vcl/parameters.vcl). The application trusts +those headers as soon as `framework.trusted_proxies` is configured, so leaving them unfiltered lets +a client spoof the scheme, host and IP address it is seen with. + +If a TLS terminator, load balancer or CDN runs in front of Varnish, add its IP to that ACL — +otherwise the `X-Forwarded-Proto` it sets is discarded and URLs are generated as `http://`. + +_Upgrading: `parameters.vcl` and the VCL above must be updated together. The VCL references the +`trusted_proxies` ACL, so an older `parameters.vcl` without it makes Varnish fail to load the +configuration._ + For tuning the VCL further to you needs, see the following relevant examples: - [FOSHttpCache documentation](https://foshttpcache.readthedocs.io/en/latest/varnish-configuration.html) - Symfony documentation [4.4](https://symfony.com/doc/4.4/http_cache/varnish.html) diff --git a/docs/varnish/vcl/parameters.vcl b/docs/varnish/vcl/parameters.vcl index b211df5..bb3cf66 100644 --- a/docs/varnish/vcl/parameters.vcl +++ b/docs/varnish/vcl/parameters.vcl @@ -26,3 +26,16 @@ acl debuggers { "127.0.0.1"; "192.168.0.0"/16; } + +// ACL for reverse proxies, TLS terminators and CDNs running in front of Varnish +// +// Only requests coming from these are allowed to set the "X-Forwarded-*" and "Forwarded" headers, +// see vcl_recv. Requests from anyone else get them stripped, as the application trusts them once +// framework.trusted_proxies is configured, which would otherwise let a client spoof the scheme, +// host and client IP it is seen with. +// +// Add the IP of your TLS terminator/load balancer/CDN here if one runs in front of Varnish, +// otherwise leave this as is. +acl trusted_proxies { + "127.0.0.1"; +} diff --git a/docs/varnish/vcl/varnish5.vcl b/docs/varnish/vcl/varnish5.vcl index 74c8343..abc396a 100644 --- a/docs/varnish/vcl/varnish5.vcl +++ b/docs/varnish/vcl/varnish5.vcl @@ -23,6 +23,29 @@ sub vcl_recv { // Add a Surrogate-Capability header to announce ESI support. set req.http.Surrogate-Capability = "abc=ESI/1.0"; + // Stop clients from injecting reverse proxy headers, as the application trusts them once + // framework.trusted_proxies is configured. See parameters.vcl for the trusted_proxies ACL. + if (client.ip !~ trusted_proxies) { + // Varnish appends client.ip to X-Forwarded-For before vcl_recv is entered, so overwrite + // rather than unset, to keep the real client IP as the only entry. + set req.http.X-Forwarded-For = client.ip; + unset req.http.Forwarded; + unset req.http.X-Forwarded-Host; + unset req.http.X-Forwarded-Prefix; + unset req.http.X-Forwarded-Port; + // To prevent the Ibexa Cloud detection in Ibexa DXP from kicking in: + unset req.http.X-Client-IP; + unset req.http.Client-Cdn; + + // Varnish itself only knows the scheme when a TLS terminator hands the connection over + // using the PROXY protocol, in which case server.ip is the address the client connected to. + if (std.port(server.ip) == 443) { + set req.http.X-Forwarded-Proto = "https"; + } else { + unset req.http.X-Forwarded-Proto; + } + } + // Ensure that the Symfony Router generates URLs correctly with Varnish if (req.http.X-Forwarded-Proto == "https" ) { set req.http.X-Forwarded-Port = "443"; diff --git a/docs/varnish/vcl/varnish6.vcl b/docs/varnish/vcl/varnish6.vcl index b7cab5d..08da92d 100644 --- a/docs/varnish/vcl/varnish6.vcl +++ b/docs/varnish/vcl/varnish6.vcl @@ -23,6 +23,29 @@ sub vcl_recv { // Add a Surrogate-Capability header to announce ESI support. set req.http.Surrogate-Capability = "abc=ESI/1.0"; + // Stop clients from injecting reverse proxy headers, as the application trusts them once + // framework.trusted_proxies is configured. See parameters.vcl for the trusted_proxies ACL. + if (client.ip !~ trusted_proxies) { + // Varnish appends client.ip to X-Forwarded-For before vcl_recv is entered, so overwrite + // rather than unset, to keep the real client IP as the only entry. + set req.http.X-Forwarded-For = client.ip; + unset req.http.Forwarded; + unset req.http.X-Forwarded-Host; + unset req.http.X-Forwarded-Prefix; + unset req.http.X-Forwarded-Port; + // To prevent the Ibexa Cloud detection in Ibexa DXP from kicking in: + unset req.http.X-Client-IP; + unset req.http.Client-Cdn; + + // Varnish itself only knows the scheme when a TLS terminator hands the connection over + // using the PROXY protocol, in which case server.ip is the address the client connected to. + if (std.port(server.ip) == 443) { + set req.http.X-Forwarded-Proto = "https"; + } else { + unset req.http.X-Forwarded-Proto; + } + } + // Ensure that the Symfony Router generates URLs correctly with Varnish if (req.http.X-Forwarded-Proto == "https" ) { set req.http.X-Forwarded-Port = "443"; diff --git a/docs/varnish/vcl/varnish7.vcl b/docs/varnish/vcl/varnish7.vcl index 6746b38..1860821 100644 --- a/docs/varnish/vcl/varnish7.vcl +++ b/docs/varnish/vcl/varnish7.vcl @@ -21,6 +21,29 @@ sub vcl_recv { // Add a Surrogate-Capability header to announce ESI support. set req.http.Surrogate-Capability = "abc=ESI/1.0"; + // Stop clients from injecting reverse proxy headers, as the application trusts them once + // framework.trusted_proxies is configured. See parameters.vcl for the trusted_proxies ACL. + if (client.ip !~ trusted_proxies) { + // Varnish appends client.ip to X-Forwarded-For before vcl_recv is entered, so overwrite + // rather than unset, to keep the real client IP as the only entry. + set req.http.X-Forwarded-For = client.ip; + unset req.http.Forwarded; + unset req.http.X-Forwarded-Host; + unset req.http.X-Forwarded-Prefix; + unset req.http.X-Forwarded-Port; + // To prevent the Ibexa Cloud detection in Ibexa DXP from kicking in: + unset req.http.X-Client-IP; + unset req.http.Client-Cdn; + + // Varnish itself only knows the scheme when a TLS terminator hands the connection over + // using the PROXY protocol, in which case server.ip is the address the client connected to. + if (std.port(server.ip) == 443) { + set req.http.X-Forwarded-Proto = "https"; + } else { + unset req.http.X-Forwarded-Proto; + } + } + // Ensure that the Symfony Router generates URLs correctly with Varnish if (req.http.X-Forwarded-Proto == "https" ) { set req.http.X-Forwarded-Port = "443"; From c978b5ac3a24299d6ed80706085ec6b1fa302c38 Mon Sep 17 00:00:00 2001 From: Vidar Langseid Date: Fri, 14 Aug 2026 13:47:53 +0200 Subject: [PATCH 2/5] IBX-11181: Added varnishtest coverage for reverse proxy header filtering --- .github/workflows/backend-ci.yaml | 16 +++++ tests/varnish/Dockerfile | 20 ++++++ tests/varnish/README.md | 39 +++++++++++ tests/varnish/fixtures/parameters-trusted.vcl | 23 +++++++ .../varnish/fixtures/parameters-untrusted.vcl | 23 +++++++ tests/varnish/run.sh | 67 +++++++++++++++++++ tests/varnish/trusted.vtc | 41 ++++++++++++ tests/varnish/untrusted.vtc | 41 ++++++++++++ 8 files changed, 270 insertions(+) create mode 100644 tests/varnish/Dockerfile create mode 100644 tests/varnish/README.md create mode 100644 tests/varnish/fixtures/parameters-trusted.vcl create mode 100644 tests/varnish/fixtures/parameters-untrusted.vcl create mode 100755 tests/varnish/run.sh create mode 100644 tests/varnish/trusted.vtc create mode 100644 tests/varnish/untrusted.vtc diff --git a/.github/workflows/backend-ci.yaml b/.github/workflows/backend-ci.yaml index a7eb3e6..5aaccae 100644 --- a/.github/workflows/backend-ci.yaml +++ b/.github/workflows/backend-ci.yaml @@ -81,6 +81,22 @@ jobs: - name: Run test suite run: composer run-script --timeout=600 test + varnish-vcl-tests: + name: Varnish VCL tests + runs-on: "ubuntu-24.04" + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + + - name: Build varnishtest image + run: docker build -t ibexa-varnishtest:7 tests/varnish + + - name: Run varnishtest suite + env: + IMAGE_VARNISH7: ibexa-varnishtest:7 + run: tests/varnish/run.sh varnish7.vcl + integration-tests: name: "REST integration tests" uses: ibexa/rest/.github/workflows/integration-tests-callable.yaml@4.6 diff --git a/tests/varnish/Dockerfile b/tests/varnish/Dockerfile new file mode 100644 index 0000000..a0f61c1 --- /dev/null +++ b/tests/varnish/Dockerfile @@ -0,0 +1,20 @@ +# Image for running the varnishtest suite in tests/varnish/. +# +# Mirrors doc/docker/Dockerfile-varnish9 from ibexa/docker: the VCLs import the xkey vmod, which the +# official image does not ship. + +FROM varnish:7.1 + +SHELL ["/bin/bash", "-c"] + +# Root is only needed to install the vmod; the image drops back to the unprivileged user the base +# image runs as by default. +USER root +RUN set -e && \ + read -ra vmod_deps <<< "$VMOD_DEPS" && \ + apt-get update && \ + apt-get -y --no-install-recommends install "${vmod_deps[@]}" /pkgs/*.deb && \ + install-vmod https://github.com/varnish/varnish-modules/releases/download/0.20.0/varnish-modules-0.20.0.tar.gz && \ + rm -rf /var/lib/apt/lists/* + +USER varnish diff --git a/tests/varnish/README.md b/tests/varnish/README.md new file mode 100644 index 0000000..64ad800 --- /dev/null +++ b/tests/varnish/README.md @@ -0,0 +1,39 @@ +Varnish VCL tests +================= + +`varnishtest` cases for the VCLs in [`docs/varnish/vcl`](../../docs/varnish/vcl). Each case loads the +real VCL file into a real Varnish, with a fixture standing in for `parameters.vcl`, and asserts on +the request the **backend** receives — which is where the reverse proxy header filtering in +`vcl_recv` can be observed. + +Cases +----- + +| Case | Fixture | Asserts | +|---|---|---| +| `untrusted.vtc` | `fixtures/parameters-untrusted.vcl` (client not in `trusted_proxies`) | every client supplied `X-Forwarded-*` / `Forwarded` / `X-Client-IP` / `Client-Cdn` is gone, `X-Forwarded-For` holds only the real client IP | +| `trusted.vtc` | `fixtures/parameters-trusted.vcl` (client in `trusted_proxies`) | the same headers are passed through untouched — guards against the filtering degrading into a blanket strip, which would break every setup with a TLS terminator or CDN in front of Varnish | + +Running them +------------ + +```bash +docker build -t ibexa-varnishtest:7 tests/varnish +IMAGE_VARNISH7=ibexa-varnishtest:7 tests/varnish/run.sh varnish7.vcl +``` + +`run.sh` with no arguments runs every VCL. `varnish5.vcl` and `varnish6.vcl` target Varnish 6.0LTS +and do not compile on 7.x — their `vcl_hit` returns `miss`, which 7.x rejects — so they need a +Varnish 6.0 image carrying xkey, which you can point at with `IMAGE_VARNISH6`. CI only runs +`varnish7.vcl`; the 6.0 line is covered end to end by the `varnish6` browser-test job, which builds +`varnish5.vcl`. + +`run.sh` also asserts that `varnish5.vcl` and `varnish6.vcl` stay identical apart from their +two-line header comment, since they are documented as 1:1 copies. + +Notes +----- + +- The backend listens on a fixed port (9081) so that the fixtures, which are static files, can + point a `backend` at it. +- Varnish rejects an unused ACL, so a fixture must define exactly the ACLs its VCL references. diff --git a/tests/varnish/fixtures/parameters-trusted.vcl b/tests/varnish/fixtures/parameters-trusted.vcl new file mode 100644 index 0000000..72ccad0 --- /dev/null +++ b/tests/varnish/fixtures/parameters-trusted.vcl @@ -0,0 +1,23 @@ +// Test fixture standing in for parameters.vcl: the client IS a trusted proxy. +// +// The backend is on a fixed port so that it can be referenced from a static file, see the +// "server s1 -listen" line in the .vtc files. + +backend ezplatform { + .host = "127.0.0.1"; + .port = "9081"; +} + +acl invalidators { + "127.0.0.1"; +} + +acl debuggers { + "127.0.0.1"; +} + +// Contains 127.0.0.1, so that varnishtest's client is treated as a reverse proxy in front of +// Varnish, the way a TLS terminator or CDN would be. +acl trusted_proxies { + "127.0.0.1"; +} diff --git a/tests/varnish/fixtures/parameters-untrusted.vcl b/tests/varnish/fixtures/parameters-untrusted.vcl new file mode 100644 index 0000000..bcfa590 --- /dev/null +++ b/tests/varnish/fixtures/parameters-untrusted.vcl @@ -0,0 +1,23 @@ +// Test fixture standing in for parameters.vcl: the client is NOT a trusted proxy. +// +// The backend is on a fixed port so that it can be referenced from a static file, see the +// "server s1 -listen" line in the .vtc files. + +backend ezplatform { + .host = "127.0.0.1"; + .port = "9081"; +} + +acl invalidators { + "127.0.0.1"; +} + +acl debuggers { + "127.0.0.1"; +} + +// Deliberately does not contain 127.0.0.1, so that varnishtest's client is treated as an +// ordinary client rather than as a reverse proxy. +acl trusted_proxies { + "192.0.2.1"; +} diff --git a/tests/varnish/run.sh b/tests/varnish/run.sh new file mode 100755 index 0000000..345c434 --- /dev/null +++ b/tests/varnish/run.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# +# Runs the varnishtest suite for the VCLs in docs/varnish/vcl/. +# +# Each case is run against a Varnish that has loaded the real VCL file, with a fixture standing in +# for parameters.vcl, so what is asserted is the shipped configuration rather than a copy of it. +# +# Usage: +# tests/varnish/run.sh # all VCLs, using Docker +# tests/varnish/run.sh varnish7.vcl # a single VCL +# +# varnish5.vcl and varnish6.vcl target Varnish 6.0LTS and do not compile on 7.x (vcl_hit returns +# miss, which 7.x rejects), so they need a different image than varnish7.vcl. Both images must +# carry the xkey vmod, since the VCLs import it. + +set -euo pipefail + +TESTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VCLDIR="$(cd "$TESTDIR/../../docs/varnish/vcl" && pwd)" + +IMAGE_VARNISH6="${IMAGE_VARNISH6:-ibexa-varnishtest:6}" +IMAGE_VARNISH7="${IMAGE_VARNISH7:-ibexa-varnishtest:7}" + +image_for() { + local vcl="$1" + + case "$vcl" in + varnish7.vcl) echo "$IMAGE_VARNISH7" ;; + *) echo "$IMAGE_VARNISH6" ;; + esac +} + +run_case() { + local vcl="$1" case_name="$2" image + image="$(image_for "$vcl")" + + echo "==> $vcl / $case_name (${image})" + docker run --rm \ + -v "$VCLDIR/$vcl:/etc/varnish/default.vcl:ro" \ + -v "$TESTDIR/fixtures/parameters-${case_name}.vcl:/etc/varnish/parameters.vcl:ro" \ + -v "$TESTDIR/${case_name}.vtc:/${case_name}.vtc:ro" \ + --entrypoint varnishtest "$image" "/${case_name}.vtc" +} + +# varnish5.vcl is kept as a 1:1 copy of varnish6.vcl for BC. Guard that, so the two cannot drift. +check_copies_in_sync() { + if ! diff <(tail -n +3 "$VCLDIR/varnish5.vcl") <(tail -n +3 "$VCLDIR/varnish6.vcl") > /dev/null; then + echo "FAIL: varnish5.vcl and varnish6.vcl differ beyond their two-line header comment." >&2 + echo " They are documented as 1:1 copies - apply every change to both." >&2 + return 1 + fi + echo "==> varnish5.vcl and varnish6.vcl are in sync" +} + +vcls=("${@:-varnish5.vcl varnish6.vcl varnish7.vcl}") +# shellcheck disable=SC2206 +vcls=(${vcls[@]}) + +check_copies_in_sync + +for vcl in "${vcls[@]}"; do + for case_name in untrusted trusted; do + run_case "$vcl" "$case_name" + done +done + +echo "All varnishtest cases passed." diff --git a/tests/varnish/trusted.vtc b/tests/varnish/trusted.vtc new file mode 100644 index 0000000..13dc972 --- /dev/null +++ b/tests/varnish/trusted.vtc @@ -0,0 +1,41 @@ +varnishtest "Proxy headers from a trusted proxy are passed on to the backend untouched" + +# Guards against the filtering turning into a blanket strip, which would break every setup with a +# TLS terminator, load balancer or CDN in front of Varnish. +# +# Run with fixtures/parameters-trusted.vcl mounted as /etc/varnish/parameters.vcl, and the VCL +# under test as /etc/varnish/default.vcl. See run.sh. + +server s1 -listen "127.0.0.1:9081" { + rxreq + + expect req.http.X-Forwarded-Host == "upstream.example" + expect req.http.X-Forwarded-Prefix == "/admin" + expect req.http.X-Forwarded-Proto == "https" + expect req.http.Forwarded == "for=203.0.113.9;host=upstream.example;proto=https" + expect req.http.X-Client-IP == "203.0.113.9" + expect req.http.Client-Cdn == "fastly" + + # Left as Varnish built it: what the proxy sent, with the proxy's own address appended. + expect req.http.X-Forwarded-For == "203.0.113.9, 127.0.0.1" + + # Derived by the VCL from the trusted X-Forwarded-Proto. + expect req.http.X-Forwarded-Port == "443" + + txresp +} -start + +varnish v1 -arg "-f /etc/varnish/default.vcl" -start + +client c1 { + txreq -url "/trusted" \ + -hdr "X-Forwarded-Host: upstream.example" \ + -hdr "X-Forwarded-Proto: https" \ + -hdr "X-Forwarded-Prefix: /admin" \ + -hdr "X-Forwarded-For: 203.0.113.9" \ + -hdr "X-Client-IP: 203.0.113.9" \ + -hdr "Client-Cdn: fastly" \ + -hdr "Forwarded: for=203.0.113.9;host=upstream.example;proto=https" + rxresp + expect resp.status == 200 +} -run diff --git a/tests/varnish/untrusted.vtc b/tests/varnish/untrusted.vtc new file mode 100644 index 0000000..04d769a --- /dev/null +++ b/tests/varnish/untrusted.vtc @@ -0,0 +1,41 @@ +varnishtest "Proxy headers from a client that is not a trusted proxy never reach the backend" + +# Run with fixtures/parameters-untrusted.vcl mounted as /etc/varnish/parameters.vcl, and the VCL +# under test as /etc/varnish/default.vcl. See run.sh. + +server s1 -listen "127.0.0.1:9081" { + rxreq + + # Everything the client made up is gone. + expect req.http.X-Forwarded-Host == + expect req.http.X-Forwarded-Prefix == + expect req.http.X-Forwarded-Proto == + expect req.http.Forwarded == + expect req.http.X-Client-IP == + expect req.http.Client-Cdn == + + # Varnish appends client.ip to X-Forwarded-For before vcl_recv is entered, so the header is + # overwritten rather than unset: the real client IP is the only entry left. + expect req.http.X-Forwarded-For == "127.0.0.1" + + # Derived by the VCL from the (now absent) X-Forwarded-Proto, not taken from the client. + expect req.http.X-Forwarded-Port == "80" + + txresp +} -start + +varnish v1 -arg "-f /etc/varnish/default.vcl" -start + +client c1 { + txreq -url "/untrusted" \ + -hdr "X-Forwarded-Host: evil.example" \ + -hdr "X-Forwarded-Proto: https" \ + -hdr "X-Forwarded-Port: 443" \ + -hdr "X-Forwarded-Prefix: /admin" \ + -hdr "X-Forwarded-For: 6.6.6.6" \ + -hdr "X-Client-IP: 6.6.6.6" \ + -hdr "Client-Cdn: fastly" \ + -hdr "Forwarded: for=6.6.6.6;host=evil.example;proto=https" + rxresp + expect resp.status == 200 +} -run From 4341459fa3c11cc8266fdc5a704326ee223cbda4 Mon Sep 17 00:00:00 2001 From: Vidar Langseid Date: Fri, 14 Aug 2026 14:03:53 +0200 Subject: [PATCH 3/5] IBX-11181: Added Behat coverage for reverse proxy header filtering --- behat_suites.yml | 4 ++ composer.json | 1 + features/setup/setup.feature | 13 ++++++ features/varnish/proxyHeaders.feature | 46 +++++++++++++++++++ .../views/tests/cache/proxy_headers.html.twig | 22 +++++++++ src/lib/Behat/Context/ProxyHeaderContext.php | 26 +++++++++++ 6 files changed, 112 insertions(+) create mode 100644 features/varnish/proxyHeaders.feature create mode 100644 src/bundle/Resources/views/tests/cache/proxy_headers.html.twig create mode 100644 src/lib/Behat/Context/ProxyHeaderContext.php diff --git a/behat_suites.yml b/behat_suites.yml index f662dc1..0abf535 100644 --- a/behat_suites.yml +++ b/behat_suites.yml @@ -28,6 +28,7 @@ httpCache: - Ibexa\Behat\Browser\Context\AuthenticationContext - Behat\MinkExtension\Context\MinkContext - Ibexa\Behat\Browser\Context\ContentPreviewContext + - Ibexa\HttpCache\Behat\Context\ProxyHeaderContext varnish6-translation-aware: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' @@ -43,6 +44,7 @@ httpCache: - Ibexa\Behat\Browser\Context\AuthenticationContext - Behat\MinkExtension\Context\MinkContext - Ibexa\Behat\Browser\Context\ContentPreviewContext + - Ibexa\HttpCache\Behat\Context\ProxyHeaderContext varnish7: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' @@ -58,6 +60,7 @@ httpCache: - Ibexa\Behat\Browser\Context\AuthenticationContext - Behat\MinkExtension\Context\MinkContext - Ibexa\Behat\Browser\Context\ContentPreviewContext + - Ibexa\HttpCache\Behat\Context\ProxyHeaderContext varnish7-translation-aware: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' @@ -73,6 +76,7 @@ httpCache: - Ibexa\Behat\Browser\Context\AuthenticationContext - Behat\MinkExtension\Context\MinkContext - Ibexa\Behat\Browser\Context\ContentPreviewContext + - Ibexa\HttpCache\Behat\Context\ProxyHeaderContext setup: paths: - '%paths.base%/vendor/ibexa/http-cache/features/setup/setup.feature' diff --git a/composer.json b/composer.json index c58cd55..7a85c82 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "toflar/psr6-symfony-http-cache-store": "^2.2 || ^3.0" }, "require-dev": { + "ibexa/behat": "~4.6.0", "ibexa/ci-scripts": "^0.2@dev", "ibexa/doctrine-schema": "~4.6.0@dev", "phpunit/phpunit": "^8.5", diff --git a/features/setup/setup.feature b/features/setup/setup.feature index ae1685c..36c5049 100644 --- a/features/setup/setup.feature +++ b/features/setup/setup.feature @@ -55,3 +55,16 @@ Feature: Set system to desired state before tests match: Identifier\ContentType: [embeddedContentType] """ + + @APIUser:admin + Scenario: Set up the system to test filtering of reverse proxy headers + Given I create a "proxyHeadersContentType" content type in "Content" with "proxyHeadersContentType" identifier + | Field Type | Name | Identifier | Required | Searchable | Translatable | + | Text line | Name | name | yes | yes | yes | + And I append configuration to "ibexa.system.default.content_view.full" + """ + proxyHeadersContentType: + template: "@IbexaHttpCache/tests/cache/proxy_headers.html.twig" + match: + Identifier\ContentType: [proxyHeadersContentType] + """ diff --git a/features/varnish/proxyHeaders.feature b/features/varnish/proxyHeaders.feature new file mode 100644 index 0000000..3b5630e --- /dev/null +++ b/features/varnish/proxyHeaders.feature @@ -0,0 +1,46 @@ +@varnish6 @varnish7 +Feature: As a site administrator I want Varnish to drop reverse proxy headers sent by clients + + # The application trusts X-Forwarded-* as soon as framework.trusted_proxies is configured, so a + # client must not be able to set them. Varnish strips them for every client that is not listed + # in the trusted_proxies ACL - which is every client here, as the ACL deliberately does not + # contain the Docker network. + # + # The rendered page reports the headers the application received, since that cannot be observed + # from the response alone. See src/bundle/Resources/views/tests/cache/proxy_headers.html.twig. + + @admin + Scenario Outline: Client supplied reverse proxy headers never reach the application + Given I create "proxyHeadersContentType" Content items in root in "eng-GB" + | name | + | | + And I am viewing the pages on siteaccess "site" as "" "" + And I set request header "X-Forwarded-Host" to "evil.example" + And I set request header "X-Forwarded-Proto" to "https" + And I set request header "X-Forwarded-Port" to "443" + And I set request header "X-Forwarded-Prefix" to "/admin" + And I set request header "X-Forwarded-For" to "6.6.6.6" + And I set request header "X-Client-IP" to "6.6.6.6" + And I set request header "Client-Cdn" to "fastly" + And I set request header "Forwarded" to "for=6.6.6.6;host=evil.example;proto=https" + When I visit "" on siteaccess "site" + # A cache hit would show the headers of whichever request populated the cache + And response headers contain + | Header | Value | + | x-cache | MISS | + Then I should see "XFHOST:-" + And I should see "XFPROTO:-" + And I should see "XFPREFIX:-" + And I should see "FORWARDED:-" + And I should see "XCLIENTIP:-" + And I should see "CLIENTCDN:-" + # Derived by the VCL from the stripped X-Forwarded-Proto, not taken from the client + And I should see "XFPORT:80" + # X-Forwarded-For is overwritten with the real client IP rather than unset + And I should not see "6.6.6.6" + And I should not see "evil.example" + + Examples: + | user | password | itemName | + | admin | publish | ProxyProbeAdmin | + | anonymous | | ProxyProbeAnonymous | diff --git a/src/bundle/Resources/views/tests/cache/proxy_headers.html.twig b/src/bundle/Resources/views/tests/cache/proxy_headers.html.twig new file mode 100644 index 0000000..ad28589 --- /dev/null +++ b/src/bundle/Resources/views/tests/cache/proxy_headers.html.twig @@ -0,0 +1,22 @@ +{# Renders the reverse proxy headers as the application received them. + + Used by features/varnish/proxyHeaders.feature to assert what Varnish forwarded to the backend, + which cannot be observed from the response alone. Test fixture, not part of the public API. #} + + + + Proxy headers received by the application + + +
+
XFF:{{ app.request.headers.get('x-forwarded-for')|default('-') }}
+
XFHOST:{{ app.request.headers.get('x-forwarded-host')|default('-') }}
+
XFPROTO:{{ app.request.headers.get('x-forwarded-proto')|default('-') }}
+
XFPORT:{{ app.request.headers.get('x-forwarded-port')|default('-') }}
+
XFPREFIX:{{ app.request.headers.get('x-forwarded-prefix')|default('-') }}
+
FORWARDED:{{ app.request.headers.get('forwarded')|default('-') }}
+
XCLIENTIP:{{ app.request.headers.get('x-client-ip')|default('-') }}
+
CLIENTCDN:{{ app.request.headers.get('client-cdn')|default('-') }}
+
+ + diff --git a/src/lib/Behat/Context/ProxyHeaderContext.php b/src/lib/Behat/Context/ProxyHeaderContext.php new file mode 100644 index 0000000..33d4015 --- /dev/null +++ b/src/lib/Behat/Context/ProxyHeaderContext.php @@ -0,0 +1,26 @@ +getSession()->setRequestHeader($name, $value); + } +} From 552ac38790bd7e8af3d1afd7a1a7522f49a37f81 Mon Sep 17 00:00:00 2001 From: Vidar Langseid Date: Fri, 14 Aug 2026 14:06:04 +0200 Subject: [PATCH 4/5] IBX-11181: Added Behat coverage for trusted proxy header pass-through --- .github/workflows/browser-tests.yaml | 26 ++++++++++++++++++++++++++ behat_suites.yml | 24 ++++++++++++++++++++---- features/varnish/proxyHeaders.feature | 26 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/browser-tests.yaml b/.github/workflows/browser-tests.yaml index 1a74054..78a6c25 100644 --- a/.github/workflows/browser-tests.yaml +++ b/.github/workflows/browser-tests.yaml @@ -47,6 +47,32 @@ jobs: AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + varnish7-trusted-proxy: + name: "Varnish 7 integration tests (trusted proxy)" + uses: ibexa/gh-workflows/.github/workflows/browser-tests.yml@main + with: + project-edition: 'oss' + setup: "doc/docker/base-dev.yml:doc/docker/varnish7.yml:doc/docker/varnish-trusted-proxy.yml:doc/docker/selenium.yml" + test-suite: '--mode=standard --profile=httpCache --suite=varnish-trusted-proxy' + test-setup-phase-1: '--mode=standard --profile=httpCache --suite=setup' + secrets: + AUTOMATION_CLIENT_ID: ${{ secrets.AUTOMATION_CLIENT_ID }} + AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} + AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + varnish6-trusted-proxy: + name: "Varnish 6 integration tests (trusted proxy)" + uses: ibexa/gh-workflows/.github/workflows/browser-tests.yml@main + with: + project-edition: 'oss' + setup: "doc/docker/base-dev.yml:doc/docker/varnish.yml:doc/docker/varnish-trusted-proxy.yml:doc/docker/selenium.yml" + test-suite: '--mode=standard --profile=httpCache --suite=varnish-trusted-proxy' + test-setup-phase-1: '--mode=standard --profile=httpCache --suite=setup' + secrets: + AUTOMATION_CLIENT_ID: ${{ secrets.AUTOMATION_CLIENT_ID }} + AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} + AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} varnish6-translation-aware: name: "Varnish integration tests (translation-aware)" uses: ibexa/gh-workflows/.github/workflows/browser-tests.yml@main diff --git a/behat_suites.yml b/behat_suites.yml index 0abf535..b60df7b 100644 --- a/behat_suites.yml +++ b/behat_suites.yml @@ -17,7 +17,7 @@ httpCache: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' filters: - tags: '@varnish6&&~@translationAware' + tags: '@varnish6&&~@translationAware&&~@trustedProxy' contexts: - Ibexa\Behat\API\Context\TestContext - Ibexa\Behat\API\Context\ContentTypeContext @@ -33,7 +33,7 @@ httpCache: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' filters: - tags: '@varnish6&&~@translationNotAware' + tags: '@varnish6&&~@translationNotAware&&~@trustedProxy' contexts: - Ibexa\Behat\API\Context\TestContext - Ibexa\Behat\API\Context\ContentTypeContext @@ -49,7 +49,23 @@ httpCache: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' filters: - tags: '@varnish7&&~@translationAware' + tags: '@varnish7&&~@translationAware&&~@trustedProxy' + contexts: + - Ibexa\Behat\API\Context\TestContext + - Ibexa\Behat\API\Context\ContentTypeContext + - Ibexa\Behat\Core\Context\TimeContext + - Ibexa\Behat\Core\Context\ConfigurationContext + - Ibexa\Behat\API\Context\ContentContext + - Ibexa\Behat\Browser\Context\BrowserContext + - Ibexa\Behat\Browser\Context\AuthenticationContext + - Behat\MinkExtension\Context\MinkContext + - Ibexa\Behat\Browser\Context\ContentPreviewContext + - Ibexa\HttpCache\Behat\Context\ProxyHeaderContext + varnish-trusted-proxy: + paths: + - '%paths.base%/vendor/ibexa/http-cache/features/varnish' + filters: + tags: '@trustedProxy' contexts: - Ibexa\Behat\API\Context\TestContext - Ibexa\Behat\API\Context\ContentTypeContext @@ -65,7 +81,7 @@ httpCache: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' filters: - tags: '@varnish7&&~@translationNotAware' + tags: '@varnish7&&~@translationNotAware&&~@trustedProxy' contexts: - Ibexa\Behat\API\Context\TestContext - Ibexa\Behat\API\Context\ContentTypeContext diff --git a/features/varnish/proxyHeaders.feature b/features/varnish/proxyHeaders.feature index 3b5630e..e5cb4f8 100644 --- a/features/varnish/proxyHeaders.feature +++ b/features/varnish/proxyHeaders.feature @@ -44,3 +44,29 @@ Feature: As a site administrator I want Varnish to drop reverse proxy headers se | user | password | itemName | | admin | publish | ProxyProbeAdmin | | anonymous | | ProxyProbeAnonymous | + + # Runs only with the varnish-trusted-proxy overlay applied, where the app container is added to the + # trusted_proxies ACL. Guards against the filtering degrading into a blanket strip, which would + # break every setup with a TLS terminator, load balancer or CDN in front of Varnish. + @admin @trustedProxy + Scenario: Reverse proxy headers from a trusted proxy do reach the application + Given I create "proxyHeadersContentType" Content items in root in "eng-GB" + | name | + | ProxyProbeTrusted | + And I am viewing the pages on siteaccess "site" as "anonymous" "" + And I set request header "X-Forwarded-Host" to "upstream.example" + And I set request header "X-Forwarded-Proto" to "https" + And I set request header "X-Forwarded-Prefix" to "/admin" + And I set request header "X-Client-IP" to "203.0.113.9" + And I set request header "Client-Cdn" to "fastly" + When I visit "ProxyProbeTrusted" on siteaccess "site" + And response headers contain + | Header | Value | + | x-cache | MISS | + Then I should see "XFHOST:upstream.example" + And I should see "XFPROTO:https" + And I should see "XFPREFIX:/admin" + And I should see "XCLIENTIP:203.0.113.9" + And I should see "CLIENTCDN:fastly" + # Derived by the VCL from the trusted X-Forwarded-Proto + And I should see "XFPORT:443" From 15d27f9c4b6d8abc2fe03d276c1bb8c096949a3d Mon Sep 17 00:00:00 2001 From: Vidar Langseid Date: Tue, 18 Aug 2026 16:02:33 +0200 Subject: [PATCH 5/5] [TMP] Added dependencies.json linking ibexa/docker PR #65 --- dependencies.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 dependencies.json diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000..c90b397 --- /dev/null +++ b/dependencies.json @@ -0,0 +1,11 @@ +{ + "recipesEndpoint": "", + "packages": [ + { + "requirement": "dev-IBX-11181-Trusted_Proxies_is_not_set_on_Ibexa_Cloud as 4.6.x-dev", + "repositoryUrl": "https://github.com/ibexa/docker", + "package": "ibexa/docker", + "shouldBeAddedAsVCS": false + } + ] +} \ No newline at end of file