Mixed content and ERR_TOO_MANY_REDIRECTS errors in WordPress when using Varnish
If you’re running a WordPress site and use Varnish to accelerate it, there are 2 typical issues you may encounter when it comes to handling TLS: mixed content and the dreaded ERR_TOO_MANY_REDIRECTS
error. Both issues are related to a lack of TLS awareness in the stack.
This tutorial will show you how to tackle both issues and how to create TLS awareness in a situation where both WordPress and Varnish might be lacking that awareness.
TLS termination with Varnish Cache
While Varnish Enterprise, the commercial version of Varnish, has native TLS support, the open source version doesn’t. This means the TLS session needs to be terminated by a TLS Proxy.
There are many TLS proxy servers out there: some of them are pure TLS proxies without any HTTP awareness, but most of them are HTTP proxies or HTTP-based load balancers. We even develop our own open source TLS proxy, called Hitch.
The diagram below shows the various HTTP components in the stack:
When calling an HTTPS-based WordPress page, the user first connects to the TLS proxy. After the TLS session has been terminated, the TLS proxy sends the unencrypted HTTP request to Varnish. Varnish will try to serve the request from the cache or fetch it from WordPress. All of this happens over plain and unencrypted HTTP once the TLS proxy has terminated the TLS session.
A lack of TLS awareness in WordPress
The fact that all communication between Varnish and WordPress happens over plain HTTP is not necessarily the issue. The fact that WordPress has no TLS awareness is the real issue.
WordPress, written in the PHP language, uses the value of the $_SERVER["HTTPS"]
superglobal variable to determine the protocol. When its value is set to on
, WordPress knows the page was requested over HTTPS, otherwise plain HTTP is served.
It is the web server that is in charge of communicating the use of HTTPS to the PHP runtime. It does so through an HTTPS environment variable.
Since the web server only receives plain HTTP requests, the HTTPS
environment variable will never be automatically enabled, which causes the mixed content problem.
Mixed content
When mixed content is served to the browser, it means that a mixture of plain HTTP and HTTPS URLs are loaded for a single page. This behavior is deemed unsafe by browsers and causes errors. Modern browsers have switched from solely displaying an error message to proactively upgrading the plain HTTP URLs to HTTPS URLs.
The error message below is an example of mixed content in WordPress:
The image below shows a breakdown of the requests that are responsible for loading mixed content:
Although the web page itself is served using HTTPS, nearly all other resources are loaded over plain HTTP. And as mentioned earlier, this is due to a lack of TLS awareness in WordPress.
Setting the X-Forwarded-Proto header
If WordPress doesn’t have TLS awareness when proxies are used, let’s give WordPress that awareness! That’s where the X-Forwarded-Proto
header comes into play. This conventional HTTP request header is used to transport the protocol of the initial user request across the various nodes in the chain.
Its value can be either be http
or https
and it’s up to the TLS proxy to set it to https
. However, if the TLS proxy has no HTTP awareness, it’s Varnish’s responsibility to set the X-Forwarded-Proto
header.
The following VCL code will check if the X-Forwarded-Proto header was set by another proxy or set it if it doesn’t exist:
import std;
import proxy;
sub vcl_recv {
if (!req.http.X-Forwarded-Proto) {
if(std.port(server.ip) == 8443 || proxy.is_ssl()) {
set req.http.X-Forwarded-Proto = "https";
} else {
set req.http.X-Forwarded-Proto = "http";
}
}
}
If the X-Forwarded-Proto
header does not exist, Varnish checks which server port was used. Whereas normal HTTP traffic goes over port 80
, port 8443
is typically used by the TLS proxy. If the request was received on that port, we know it was an HTTPS request.
If that was the case, we set the value of the X-Forwarded-Proto
to HTTPS. Otherwise we set it to HTTP.
Alternatively, if a connection was made using the PROXY protocol, we can use the proxy VMOD to determine whether TLS was used. The proxy.is_ssl()
function can simply return true or false depending on the protocol that was used.
While most frameworks and CMS offer native support for the X-Forwarded-Proto
header, WordPress doesn’t. And that’s what we need to fix.
Checking the X-Forwarded-Proto header in WordPress
The easiest way to make WordPress TLS aware is by checking the X-Forwarded-Proto
header in the wp-config.php
file.
Simply add the following code in your wp-config.php
file to solve the mixed content issue:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
$_SERVER['HTTPS'] = 'on';
}
wp-config.php
. This means mixed content is not a factor when using an official WordPress Docker image.X-Forwarded-Proto
check in your wp-config.php
file, you can set the header in your web server configuration. The paragraphs below describe how to set the X-Forwarded-Proto
header directly in Apache or Nginx.Checking the X-Forwarded-Proto header in Apache
If you’re using Apache as your web server and you cannot set the X-Forwarded-Proto
header in your WordPress configuration, simply add the following line to your .htaccess
file to add the X-Forwarded-Proto
header to the HTTP response:
SetEnvIf X-Forwarded-Proto "https" HTTPS=on
This line will conditionally set the HTTPS
environment variable to on
, and that allows WordPress to generate HTTPS URLs.
Checking the X-Forwarded-Proto header in Nginx
If you’re using Nginx instead of Apache as your web server and you cannot set the X-Forwarded-Proto
header in your WordPress configuration, you can also check the value of the X-Forwarded-Proto
header in your Nginx configuration.
Unlike Apache, Nginx doesn’t directly interact with the PHP runtime, instead connects to the PHP runtime over the FastCGI protocol. The PHP-FPM service that runs the WordPress code runs on port 9000
, which the Nginx web server connects to, as shown in the example below.
By setting the HTTPS
FastCGI parameter, WordPress can be made aware of the original client protocol. The value again depends on the X-Forwarded-Proto
header, which we check and whose value we store in a custom $forwarded_https
variable.
It’s that $forwarded_https
variable we use to assign a value to the HTTPS
FastCGI parameter, as seen in the configuration below:
server {
listen 80;
root /var/www/html;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
set $forwarded_https "Off";
if ($http_x_forwarded_proto = "https") {
set $forwarded_https "On";
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
include fastcgi_params;
fastcgi_param HTTPS $forwarded_https;
}
}
We’ve solved the mixed content issue
Thanks to the X-Forwarded-Proto
header and the protocol awareness we provided to WordPress, mixed content no longer appears.
The image below shows that all resources are now loaded using HTTPS:
One problem fixed, let’s move on to the next one.
The ERR_TOO_MANY_REDIRECTS error
When your browser returns the ERR_TOO_MANY_REDIRECTS
error, it means it got stuck in an infinite redirect loop and gave up after a number of attempts.
This can also be related to a lack of TLS awareness and usually happens when HTTP to HTTPS redirection is enforced in WordPress.
Here’s an example of an HTTP to HTTPS redirection in WordPress. We call the plain HTTP version of the homepage using curl
, and you see the 301 status code and the Location header that redirects you to the HTTPS version:
$ curl -I http://localhost
HTTP/1.1 301 Moved Permanently
Date: Tue, 21 Nov 2023 14:00:24 GMT
Server: Apache/2.4.56 (Debian)
X-Powered-By: PHP/8.0.30
X-Redirect-By: WordPress
Location: https://localhost/
Content-Length: 0
Content-Type: text/html; charset=UTF-8
X-Varnish: 262374
Age: 0
Via: 1.1 varnish (Varnish/7.4)
Connection: keep-alive
Varnish itself isn’t aware that the HTTP version of a response can sometimes differ from the HTTPS version. When Varnish fetches the plain HTTP version of an HTTPS-enforced WordPress page and stores it in the cache, the 301 response is essentially cached.
As long as the cached object hasn’t expired, the 301 redirect is going to be served to everyone, even if the request was originally made over HTTPS. That’s how you get stuck in a redirect loop and that’s why the ERR_TOO_MANY_REDIRECTS
appears.
Vary: X-Forwarded-Proto
The way we tackle this issue is by giving Varnish some TLS awareness. Although Varnish sets the X-Forwarded-Proto
header, it doesn’t use the value of that header when creating a lookup hash.
When looking up objects in the cache, Varnish uses the URL and the Host
header to create a lookup hash. There is no reference to the protocol or URL scheme. By creating a cache variation based on the X-Forwarded-Proto
header, Varnish will have multiple objects stored in cache for the same URL and Host
header combination, basically storing an HTTP and an HTTPS version of each page.
The conventional way of informing HTTP caches about variations is by setting a Vary
header. The value of this response header is a valid request header. In our case that would be Vary: X-Forwarded-Proto
.
This means that we’re letting Varnish know that cache variations need to be made based on the value of the X-Forwarded-Proto
request header it receives during requests.
There are many ways of doing this. Let’s focus on a couple of specific ones.
Setting the Vary header in Apache
If you’re using Apache as your web server, you can add the following line to your .htaccess
file:
Header append Vary: X-Forwarded-Proto
This configuration will either set the Vary header with X-Forwarded-Proto
as its value, or it will append X-Forwarded-Proto
to any existing Vary
header.
If you’re already checking the X-Forwarded-Proto
header in Apache, these are the 2 lines that will be added:
SetEnvIf X-Forwarded-Proto "https" HTTPS=on
Header append Vary: X-Forwarded-Proto
Setting the Vary header in Nginx
If you’re using Nginx as a web server, there is similar syntax to add a Vary
header. Simply add this line to your vhost configuration:
add_header Vary X-Forwarded-Proto;
If you’re already checking the X-Forwarded-Proto
header in Nginx, this is what your config may look like:
server {
listen 80;
root /var/www/html;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
set $forwarded_https "Off";
if ($http_x_forwarded_proto = "https") {
set $forwarded_https "On";
}
add_header Vary X-Forwarded-Proto;
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
include fastcgi_params;
fastcgi_param HTTPS $forwarded_https;
}
}
Adding protocol awareness in Varnish
Instead of adding the Vary
header through your web server, you could also add protocol awareness to Varnish and ensure the value of the X-Forwarded-Proto
header is used to create the lookup hash. To do this, simply add this code to your VCL file:
sub vcl_hash {
if(req.http.X-Forwarded-Proto) {
hash_data(req.http.X-Forwarded-Proto);
}
}
This VCL code will extend the lookup hash and will add the value of the X-Forwarded-Proto
header to that hash when performing cache lookups. This means that the URL, the host and the scheme are used to identify an object in the cache.
Vary: X-Forwarded-Proto
in your web server or in WordPress. Using a Vary
header is a more conventional and portable solution than using VCL code.What about Varnish Enterprise?
As mentioned in the beginning of this tutorial: Varnish Enterprise has native TLS support. This means Varnish Enterprise can receive HTTPS requests from clients, but Varnish Enterprise can also forward HTTPS requests to WordPress. This also means that Varnish Enterprise has backend TLS support.
If you configured TLS in Varnish Enterprise, incoming connections over HTTPS will not be a problem. If you want to send HTTPS requests to WordPress, simply add .ssl = 1;
to your backend definition as seen below:
backend default {
.host = "example.com";
.port = "443";
.ssl = 1;
}
If you’re enforcing HTTP to HTTPS redirection, we suggest doing this in Varnish Enterprise before WordPress gets hit. Because all backend requests are done over HTTPS, even if the incoming request is plain HTTP. To avoid the opposite kind of mixed content, simply add the following VCL code to your VCL file:
import std;
sub vcl_recv {
unset req.http.location;
if (std.port(server.ip) != 443) {
set req.http.location = "https://" + req.http.host + req.url;
return (synth (301));
}
}
sub vcl_synth {
if (resp.status == 301 ||
resp.status == 302 ||
resp.status == 303 ||
resp.status == 307) {
if (!req.http.location) {
std.log("location not specified");
set resp.status = 503;
return (deliver);
} else {
set resp.http.location = req.http.location;
return (deliver);
}
}
}
This VCL code will redirect plain HTTP requests to their HTTPS variant before the cache is even touched.