is_ssl() is inadequate in load-balanced environments

If your web server is serving your site directly, – and is not behind Varnish, Fastly, Akamai, an ELB, some other proxy – this post is not for you. If you are indeed behind a proxy, you may find yourself in an awkward place when your site upgrades to HTTPS. My site, the (failing) New York Times, recently did so.

NYT

Various properties in the NYT ecosystem still run on WordPress. One of those, which will remain unnamed, is behind AWS Elastic Load Balancing, which is behind Fastly. HTTPS terminates at Fastly, which sets some HTTP headers and passes them down to the ELB on port 80. The ELB then sends port 80 traffic to a reverse DNS proxy that figures out that a certain app in our Kubernetes cluster should receive the traffic.

If you notice, all of the backend connections are happening on port 80, which is not where HTTPS traffic goes. How does this work in WordPress then, which expects HTTPS to be on port 443? (long pause) It doesn’t.

What does WordPress do?

WP checks 2 things:
1) whether $_SERVER['HTTPS'] is truthy
2) whether we are on port 443

See here: https://core.trac.wordpress.org/browser/tags/4.7/src/wp-includes/load.php#L960.

When a request is delivered via proxy, all links that are generated dynamically will have a protocol of http:, not https:. This makes sense because nothing that WordPress is checking for resembles an HTTPS request.

Why, god?

Surely, this has to be a problem elsewhere that has been identified and addressed? Correct. This is where the concept of trusted proxies and proxy HTTP headers come in. The server handling the request on behalf of the backend app can send HTTP headers downstream, alerting an app running on port 80 that it was requested over HTTPS on port 443 originally. These headers look like so:

X-Forwarded-For: OriginatingClientIPAddress, proxy1-IPAddress, proxy2-IPAddress
X-Forwarded-Proto: https
X-Forwarded-Port:  443

X-Forwarded-Proto is useful. Fastly can be configured to set this header when Fastly-SSL is true:

if (req.http.Fastly-SSL) {
  set req.http.X-Forwarded-Proto = "https";
}

The ELB can set its lb_protocol to tcp, instead of http. This will pass the HTTP request through, instead of creating a new HTTP request with proxy headers that may overwrite those from Fastly.

Fixing WordPress

But we still have a major problem: WordPress doesn’t have any way of patching is_ssl() to respect these client headers. The reasons why are too exhausting to debate – see: https://core.trac.wordpress.org/ticket/31288.

tl;dr The advice in the ticket is, basically, write your own code to handle this issue. Write your own code to handle trusted HTTP proxies and patch your servers to overwrite fastcgi params (vars that end up in $_SERVER in PHP). Most humans on earth will want to avoid this.

Next big question: how does a “real” PHP framework handle this? This is where we open our hymnals to Symfony\HttpFoundation: https://github.com/symfony/http-foundation/blob/master/Request.php#L1186. Well, there ya go.

Next problem: how can I patch is_ssl() with HttpFoundation\Request->isSecure()? Once again, you can’t, but you can use WordPress against itself and patch URL generation, which is where we have the most blatant problem (https URL in the browser bar, http links on the page).

What we did

Here’s our custom NYT\isSSL() function:

namespace NYT;

function isSSL() {
  $app = getApp();
  $request = $app['request'];
  if ( empty( $request->getTrustedProxies() ) ) {
    $request->setTrustedProxies( $request->getClientIps() );
  }
  return $request->isSecure();
}

All of our client IPs are in private IP ranges, so we are assuming that our proxy is trusted. This logic could be extended to include a whitelist instead – your call.

$app is a DI Container:

namespace NYT;

use Pimple\Container;

class App extends Container {}

We have a Symfony provider for our DI container, excerpted here:

namespace NYT\Symfony;

use Pimple\Container;
use Pimple\ServiceProviderInterface;
use Symfony\Component\HttpFoundation\Request;

class Provider implements ServiceProviderInterface {
  public function register( Container $app ) {
    $app['request'] = function () {
      return Request::createFromGlobals();
    };
  }
}

We also have a Sunrise class to patch multisite:

namespace NYT;

class Sunrise {
  protected $siteHostname;

  public function __construct( App $app ) {
    ...
    add_filter( 'option_home', [ $this, 'filterHost' ] );
    add_filter( 'option_siteurl', [ $this, 'filterHost' ] );
    ...
  }

  public function filterHost( string $host ): string
  {
    $path = parse_url( $host, PHP_URL_PATH );
    $protocol = isSSL() ? 'https://' : 'http://';
    return $protocol . $this->siteHostname . $path;
  }
}

Conclusion

This “works” for front-end traffic to our site but is not immune to every other place that is_ssl() will return the wrong value. We don’t use WordPress login on the front end (behind Fastly), so we are ignoring the fact that our cookies will still be in the wrong scheme. None of our Fastly stuff is in front of our internal tools, so we don’t require extra HTTP headers to determine the protocol.

If you search the WP codebase for is_ssl(), your stomach might sink and you might be convinced of the need for is_ssl() to be patched universally.