Mixed content and ERR_TOO_MANY_REDIRECTS errors in WordPress when using Varnish

Tags: wordpress (2) ops (27) vcl (30)

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:

TLS termination diagram with Varnish and WordPress

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:

Mixed content in WordPress due to a lack of TLS awareness

The image below shows a breakdown of the requests that are responsible for loading mixed content:

A breakdown of the mixed content in WordPress

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.

A diagram of the use of the X-Forwarded-Proto header in WordPress with a TLS proxy and Varnish

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';
}

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:

A breakdown of WordPress requests after having solved the mixed content issue

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.

The ERR_TOO_MANY_REDIRECTS error in the browser

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.

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.