$this = (new Soapbox())->shout(array_map('strtoupper', $opinions)); //Shaun's blog

Implementing a report-uri endpoint for Expect-CT (and other headers)

Posted December 13, 2017 by shaun

Yesterday, Scott Arciszewski from ParagonIE published The 2018 Guide to Building Secure PHP Software. If you develop for (or attack) the web in any language, this is an excellent reference that addresses a number of common web application pitfalls.

In the section devoted to security headers, I saw a reference to one that's flown under my radar until now: Expect-CT. Scott Helme has a nice write-up of Expect-CT, and I went off to read the specification for myself. As it happens, the HTTP Working Group just posted an updated draft today, but the changes aren't substantive.

The executive summary of Expect-CT is that Chrome can now compare the SSL/TLS certificates it encounters against the public transparency logs published by each Certificate Authority. By sending an Expect-CT header, you as a site operator are instructing Chrome to enable this protection mechanism for your site. If the browser finds a discrepancy, it can perform two different actions at your discretion:

  • Terminate the current connection, protecting the user from an impostor / MITM / other risk;

  • Submit a report containing information about the request to a report-uri you specify in your header.

In this way, Expect-CT helps your users and also serves as an early warning system for certificate forgery and misuse. If someone who's visited your site (and received your header, which Chrome stores) later encounters another site impersonating yours, or if something hinky is detected with your TLS certificate, Chrome will post a notification to let you know.

So, off to the races! Time to deploy Expect-CT on all the sites, right? Well, maybe not so fast.

I needed a reporting endpoint first, somewhere to receive any notifications that browsers might send, because I never set one up when implementing Content-Security-Policy headers. Scott Helme's post pointed to the report-uri.io service. This looks like a fine solution, and it might be a perfect, hassle-free answer for a lot of administrators. But I tend to have reservations about offloading things like this to a third party whose actions I can't control. I also suffer from a mild case of NIH, so I rolled my own, and I'm sharing it here in case it's useful to others.

After reviewing the specs a few times, there are a few considerations to keep in mind for an Expect-CT report handler. Per section 2.1.1,

For example, if connecting to the "report-uri" itself incurs an 
Expect-CT failure or other certificate validation failure, the UA
MUST cancel the connection. [...]

If a browser spots a problem with your site's certificate, it won't submit a report to a URI on the exact same host. In other words, if www.example.com's Expect-CT header specifies a report-uri that's also on www.example.com, it might as well not be set at all, because browsers will refuse to report there. It's probably a good idea to place your report-uri handler on its own hostname with its own certificate. This can be either a subdomain, an entirely different domain that you dedicate to this purpose, or some third party service.

Additionally, consider not setting the Expect-CT header on your reporting host. It isn't required there; in fact, your report-uri doesn't have to be using HTTPS at all (but it should anyway; Let's Encrypt certs are free). By omitting Expect-CT on your reporting host, you avoid potential problems should anything ever go wrong with its certificate.

With all of that in mind, here's the script I came up with as a target for the report-uri. It's generic enough that it should be suitable for any security header with a report-uri parameter (Content-Security-Policy, Public-Key-Pins, etc.) but has only been tested with Expect-CT.

 * report-uri-expect-ct.php
 * From <https://shaunc.com/go/Xdf4cU8EurV1>
 * This script accepts incoming Expect-CT browser reports and emails their
 * contents to the site administrator. For more about the Expect-CT header, see:
 * <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-02>
 * <https://scotthelme.co.uk/a-new-security-header-expect-ct/>
 * Point to this script in the "report-uri" parameter of your Expect-CT header.
 * Some browsers send an OPTIONS request first, prior to POSTing the report,
 * to ensure the server is willing to accept a POST in the first place. This
 * is known as a "pre-flight," as defined here:
 * <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests>
 * The appropriate response will be sent depending upon the HTTP verb.

define('ADMIN_EMAIL', 'you@example.com');

//Test for a CORS preflight

    //Confirm we accept POST in addition to OPTIONS
    header('Access-Control-Allow-Methods: OPTIONS, POST');

    //Numerous domains point here, so indicate that any origin is acceptable
    header('Access-Control-Allow-Origin: *');

    //Explicitly allow whatever custom headers, if any, the requestor hinted
    foreach (getallheaders() as $key=>$val) {
        if (strcasecmp($key, 'Access-Control-Request-Headers') == 0) {
            header('Access-Control-Allow-Headers:' . $val);

//Test for a posted report
    && strlen($json = @file_get_contents('php://input')) > 0) {

    //Turn the JSON report into a slightly more readable array
    $report = var_export(json_decode($json, true), true);

    //Grab the headers, too
    $headers = var_export(getallheaders(), true);

    //Build a message
    $body = <<<EOT
An Expect-CT report was posted by {$_SERVER['REMOTE_ADDR']}.

Headers follow:


Report follows:


    //Send the email
        '[' . $_SERVER['SERVER_NAME'] . '] Expect-CT Report',
        $body, 'From: ' . ADMIN_EMAIL);

Using this blog as a test environment, I set up meta.shaunc.com and installed the above script to it. Then I edited Apache's config file and placed this directive inside the <VirtualHost> stanza for shaunc.com:

Header always set Expect-CT "max-age=0, report-uri=\"https://meta.shaunc.com/report-uri/expect-ct\""

This puts things into reporting-only mode, which is sufficient for my purposes for the time being. I followed up with a couple of tests; first, one to check the response to an OPTIONS pre-flight:

[user@host]$ curl -i -X OPTIONS \
>    -H "Access-Control-Request-Headers: POST, Monkey, X-Jolt-Cola" \
>    https://meta.shaunc.com/report-uri/expect-ct
HTTP/1.1 200 OK
Date: Thu, 14 Dec 2017 00:39:05 GMT
Server: Apache
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; ...
X-Frame-Options: DENY
Strict-Transport-Security: max-age=2592000
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Access-Control-Allow-Methods: OPTIONS, POST
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: POST, Monkey, X-Jolt-Cola
Content-Length: 0
Content-Type: text/html; charset=UTF-8

Here the response tells the user-agent that the script is willing to accept POST requests, and it's also fine with whatever extra HTTP headers might be sent in a request. In the next test I simulated POSTing some JSON to the script, albeit with junk values instead of mocking up a real report:

[user@host]$ curl -i -X POST \
> -H "Content-Type: application/expect-ct-report+json" \
> -d '{"foo":"bar", "date":"2017-12-14T00:00:01+00:00"}' \
> https://meta.shaunc.com/report-uri/expect-ct
HTTP/1.1 200 OK
Date: Thu, 14 Dec 2017 00:42:37 GMT
Server: Apache
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; ...
X-Frame-Options: DENY
Strict-Transport-Security: max-age=2592000
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Length: 0
Content-Type: text/html; charset=UTF-8

The second test generated an email, as expected.

I'm already starting to see some reports come in, because Let's Encrypt doesn't implement Certificate Transparency yet. When their certificates start including this feature (slated for February 2018), I'll be able to deploy Expect-CT headers into production environments as yet another layer of security.

Recent articles

📰 Evaluating DNSBL Effectiveness with Postfix Logs

📰 Russian/Ukrainian Referer Spam Campaign IPs

📰 Resolving subversion error E145001: Node has unexpectedly changed kind

📰 Installing PHP 7.2 with pthreads on CentOS 6

📰 LocalStorage kills another site, or: Working around Zap2it's new interface

📰 A new DNS geolocation service from PowerDNS

📰 Firefox's privacy.resistFingerprinting option reports a very old User-Agent (50.0)

📰 Undefined symbol "Py_InitModule4_64" while upgrading harfbuzz

📰 ipid.shat.net is back online for now

📰 Implementing a report-uri endpoint for Expect-CT (and other headers)

📰 A curious UDAP packet from DirecTV hardware

📰 Secure PHP file inclusion based on query string parameters

▲ Back to top | Permalink to this page