Skip to content
Open
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
16 changes: 16 additions & 0 deletions .github/workflows/backend-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/browser-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@
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

Check failure on line 65 in .github/workflows/browser-tests.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use full commit SHA hash for this dependency.

See more on https://sonarcloud.io/project/issues?id=ibexa_http-cache&issues=AaAVJ3OqfKmXowy5Qn5M&open=AaAVJ3OqfKmXowy5Qn5M&pullRequest=87
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
Expand Down
28 changes: 24 additions & 4 deletions behat_suites.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,11 +28,12 @@ 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'
filters:
tags: '@varnish6&&~@translationNotAware'
tags: '@varnish6&&~@translationNotAware&&~@trustedProxy'
contexts:
- Ibexa\Behat\API\Context\TestContext
- Ibexa\Behat\API\Context\ContentTypeContext
Expand All @@ -43,11 +44,12 @@ 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'
filters:
tags: '@varnish7&&~@translationAware'
tags: '@varnish7&&~@translationAware&&~@trustedProxy'
contexts:
- Ibexa\Behat\API\Context\TestContext
- Ibexa\Behat\API\Context\ContentTypeContext
Expand All @@ -58,11 +60,28 @@ httpCache:
- 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
- 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
varnish7-translation-aware:
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
Expand All @@ -73,6 +92,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'
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions dependencies.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
14 changes: 14 additions & 0 deletions docs/varnish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions docs/varnish/vcl/parameters.vcl
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
23 changes: 23 additions & 0 deletions docs/varnish/vcl/varnish5.vcl
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
23 changes: 23 additions & 0 deletions docs/varnish/vcl/varnish6.vcl
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
23 changes: 23 additions & 0 deletions docs/varnish/vcl/varnish7.vcl
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
13 changes: 13 additions & 0 deletions features/setup/setup.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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]
"""
72 changes: 72 additions & 0 deletions features/varnish/proxyHeaders.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
@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 |
| <itemName> |
And I am viewing the pages on siteaccess "site" as "<user>" "<password>"
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 "<itemName>" 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 |

# 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"
22 changes: 22 additions & 0 deletions src/bundle/Resources/views/tests/cache/proxy_headers.html.twig
Original file line number Diff line number Diff line change
@@ -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. #}
<!DOCTYPE html>
<html lang="en">
<head>
<title>Proxy headers received by the application</title>
</head>
<body>
<div id="proxy-headers">
<div>XFF:{{ app.request.headers.get('x-forwarded-for')|default('-') }}</div>
<div>XFHOST:{{ app.request.headers.get('x-forwarded-host')|default('-') }}</div>
<div>XFPROTO:{{ app.request.headers.get('x-forwarded-proto')|default('-') }}</div>
<div>XFPORT:{{ app.request.headers.get('x-forwarded-port')|default('-') }}</div>
<div>XFPREFIX:{{ app.request.headers.get('x-forwarded-prefix')|default('-') }}</div>
<div>FORWARDED:{{ app.request.headers.get('forwarded')|default('-') }}</div>
<div>XCLIENTIP:{{ app.request.headers.get('x-client-ip')|default('-') }}</div>
<div>CLIENTCDN:{{ app.request.headers.get('client-cdn')|default('-') }}</div>
</div>
</body>
</html>
Loading
Loading