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


Me, elsewhere

GitHub
parseword
Miscellaneous public code

Twitter
@parseword
I don't tweet much

XMPP chat
xmpp@shaunc.com
(Pidgin, Miranda, Swift, etc.)

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.

<?php
/*
 * 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
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {

    //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:' . htmlentities($val, ENT_QUOTES));
            break;
        }
    }
    exit;
}

//Test for a posted report
elseif ($_SERVER['REQUEST_METHOD'] == 'POST'
    && 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:

$headers

Report follows:

$report
EOT;

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

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.


PHP logo by Colin Viebrock (http://php.net/logos) CC BY-SA 4.0, via Wikimedia Commons



Recent articles

📰 Generating vanity DNSSEC key tags

📰 DDoS involving forged packets from 23.225.141.70

📰 Website integrity monitoring through version control

📰 SpamAssassin 3.4.2 fixes security problems, adds HashBL and phishing plugins

📰 Bug or turf war? ICQ via Pidgin now fails with "startOSCARSession: Request Timeout"

📰 🎂

📰 SFSQuery, a PHP class to query the StopForumSpam API and DNSBL

📰 Resolving portmaster error "pkg-static: automake-1.16.1 conflicts with automake-wrapper-20131203"

📰 Resolving LibreNMS error "RuntimeException: The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths"

📰 1.1.1.1: Fast, but not so accurate (yet)

📰 autodiscover.xml as an Indicator of Attack

📰 Blocking Facebook's Tracking and Surveillance: A Comprehensive Approach

📰 Let's Encrypt Readies for Certificate Transparency with Embedded SCTs

📰 Evaluating DNSBL Effectiveness with Postfix Logs

📰 Resolving subversion error E145001: Node has unexpectedly changed kind

▲ Back to top | Permalink to this page