CSP logging with nginx

Background

The Content-Security-Policy HTTP response header has a huge number of ways in which you can limit the kinds of resources that your site is permitted to load. This can defend against XSS attacks and other kinds of content injection. It’s very powerful and, depending on how your site is built, can be fairly simple to implement by adding a header to every response that you send, either in your application or in the web server configuration.

CSP headers can include a directive to report to an API when one of these restrictions has been enforced. Also, if you’re working on implementing CSP, you don’t need to go straight to enforcement–the Content-Security-Policy-Report-Only header lets you try out a policy and see what would happen if you deployed it for real. The report-uri or report-to directives allow you to specify where to send reports when a policy has, or would have been, enforced. These are simple JSON payloads sent via an HTTP POST to a URL that you specify.

There’s a migration going on from the original report-uri specification to the new report-to one. In the former, you just specified a URL to POST to along the lines of this, taken from the above Mozilla page:

Content-Security-Policy: default-src https:; report-uri /csp-violation-report-endpoint/

However the report-to specification is much more flexible and so a bit more complex to set up. It works in conjunction with the new Report-To header, and so would look like this:

Report-To: { "group": "csp-reports",
             "max_age": 10886400,
             "endpoints": [
               { "url": "https://example.com/reports" },
               { "url": "https://backup.com/reports" }
             ] } 

Content-Security-Policy:  default-src https:; report-to csp-reports; report-uri https://example.com/reports

As you can see, you can include the older report-uri directive as well, for browsers which only support that. Those that support report-to will ignore report-uri when both are specified.

Logging CSP reports via nginx

While there are a number of third-party services which can be used to log these reports, it’s not all that difficult to log them yourself. With nginx, you can use a custom log format to do so. This looks something like the below. I’m using a Debian-packaged nginx and have listed the files where I’ve placed this configuration.

conf.d/csp.conf

log_format CSP escape=json '{"date":"$time_local", "IP address":"$remote_addr", "http_x_forwarded_for":"$http_x_forwarded_for", "status":"$status", "http_user_agent":"$http_user_agent", "body_bytes_sent":"$body_bytes_sent", "request":"$request","request_body": "$request_body"}';

sites-available/default

server {
    listen 443 ssl http2 default_server;
    server_name example.org;

    # More config here

    add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' fonts.googleapis.com ssl.google-analytics.com; font-src 'self' fonts.gstatic.com; style-src 'self' 'unsafe-inline' fonts.googleapis.com; img-src 'self' ssl.google-analytics.com; report-uri https://example.org/_csp";

    location = /_csp {
            access_log /var/log/nginx/csp.log CSP;
            proxy_pass http://127.0.0.1/_csp_response;
    }
}

server {
    listen 80 default_server;
    server_name example.org;

    # More config here

    location /_csp_response {
            access_log off;
            return 204;
    }
}

At first glance, the proxy_pass directive may look a bit suspicious. The reason it’s there is because if you just do return 204 directly from the /_csp location, the request body is not logged in the csp.log file. By using the proxy_pass hack, it is. You may also notice in this example I’m only configuring the older report-uri directive.

Something that would be good to do would be to limit the above requests to only HTTP POSTs, and also ensure that the requests have the correct Content-Type header for the CSP report (which would be application/csp-report). I’m not doing that in this case to keep the example clearer, but I’d certainly think about it in production.

The actual reports look like this in the log file. If you’re using an older nginx, before 1.11.8, it may not support the escape=json part of the log format and you may see all of the " characters in your log being replaced with \x22. You’ll probably want to upgrade to fix that, not least because that version is quite old!

{"date":"21/Jan/2020:21:58:46 +0000", "IP address":"198.51.100.4", "http_x_forwarded_for":"", "status":"204", "http_user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36", "body_bytes_sent":"0", "request":"POST /_csp HTTP/2.0","request-body": {\"csp-report\":{\"document-uri\":\"https://example.org/\",\"referrer\":\"\",\"violated-directive\":\"font-src\",\"effective-directive\":\"font-src\",\"original-policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' fonts.googleapis.com ssl.google-analytics.com gist.github.com; font-src 'self' fonts.gstatic.com; style-src 'self' 'unsafe-inline' fonts.googleapis.com github.githubassets.com; img-src 'self' ssl.google-analytics.com; report-uri https://example.org/_csp\",\"disposition\":\"report\",\"blocked-uri\":\"\",\"line-number\":366,\"column-number\":179,\"source-file\":\"https://example.org/assets/js/modernizr.js\",\"status-code\":0,\"script-sample\":\"\"}}}

From here you can use tools like jq to inspect what you find. You’ll note that the actual report is recorded in the request_body property in the JSON, and that this contents is escaped. You can recover this like so:

$ cat /var/log/nginx/csp.log | jq -r '.request_body' | jq

{
  "csp-report": {
    "document-uri": "https://example.org/",
    "referrer": "",
    "violated-directive": "font-src",
    "effective-directive": "font-src",
    "original-policy": "default-src 'self'; script-src 'self' 'unsafe-inline' fonts.googleapis.com ssl.google-analytics.com gist.github.com; font-src 'self' fonts.gstatic.com; style-src 'self' 'unsafe-inline' fonts.googleapis.com github.githubassets.com; img-src 'self' ssl.google-analytics.com; report-uri https://example.org/_csp",
    "disposition": "report",
    "blocked-uri": "",
    "line-number": 366,
    "column-number": 179,
    "source-file": "https://example.org/assets/js/modernizr.js",
    "status-code": 0,
    "script-sample": ""
  }
}

From this I can tell that Chrome is not entirely happy with how Modernizr is doing some feature detection around web fonts. I’ll need to look into that, but that is for another day. Later work could also include implementing the other parts of the report-to spec, which include neat things like network error logging, as explained here by Scott Helme.

NB: You will almost certainly want to add the csp.log file to your logrotate configuration, or something similar, otherwise you may end up with a chunky file eating a lot of disk space if your site doesn’t quite comply with your policy!