Monitoring Content Security

Joris Verbogt
Joris Verbogt
Mar 18 2022
Posted in Engineering & Technology

Use your own API to report CSP violations

Monitoring Content Security

Content Security Policy

A Content Security Policy is a set of directives that determine which type of content (and from which origin) is allowed to be rendered or executed in a web page. It allows you to set fine-grained access control for scripts, images, fonts, styles and iframes.

An example of such a Content Security Policy is as follows:

default-src 'self'; 
img-src 'self'; 
script-src 'self'; 
form-action 'self'; 
frame-ancestors 'self';

Which tells the browser it should only allow content that originates in the same origin as the page it is rendering.

In a real-life situation, you would probably want to allow scripts from outside sources, fonts from Google Fonts, images from an asset CDN, etc. These origins can be added explicitly:

default-src 'self'; 
img-src https://cdn.example.com 'self'; 
script-src https://maps.googleapis.com 'self'; 
form-action 'self'; 
frame-ancestors 'self';

These directives are sent along as an HTTP response header, aptly named content-security-policy:

content-security-policy: default-src 'self'; img-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'self';

Any piece of content that violates this policy will not be executed, rendered or used in the page. It will also generate a content security violation in the browser's console:

The question is: will you as the website owner see all these violations? They happen in the browser of the visitor, wouldn't it be nice to have the possibility to be alerted when they happen?

CSP Reporting

Luckily for you, there is a way to tell the browser to report these violations to an API endpoint.

To enable reporting, we need to add some directives to tell the browser where to send these reports. Modern browsers use the Reporting API to send various reports, this is also used for CSP. The URL for these reporting endpoints is defined by the report-to HTTP header, like so:

report-to: {"group": "csp-endpoint", "max_age": 10886400, "endpoints": [{ "url": "https://api.example.com/report" }]}

We then add the group as a reporting directive to our CSP:

content-security-policy: default-src 'self'; img-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp-endpoint; report-uri https://api.example.com/report

Notice that we also added a report-uri directive with the same URL. This is used by browsers that do not support the Reporting API. Technically, the report-uri directive is deprecated, but since not all browsers implement the new Reporting API yet, we need to implement it in order to catch all CSP reports. In our example we will handle both types of reports.

Example CSP Reports

Let's say your homepage tries to load the Google Maps Javascript SDK, which is of course not hosted on your own domain. Since your CSP only allows scripts to be included from the same origin as the page itself, the browser will generate a CSP violation.

It will be sent as an HTTP POST request with content type application/reports+json. It will look something like this:

[{
    "age": 0,
    "body": {
        "blockedURL": "https://maps.googleapis.com/maps/api/js",
        "disposition": "enforce",
        "documentURL": "https://test.example.com/",
        "effectiveDirective": "script-src",
        "originalPolicy": "default-src 'self'; img-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp-endpoint; report-uri https://api.example.com/report",
        "referrer": "",
        "statusCode": 200
    },
    "type": "csp-violation",
    "url": "https://test.example.com/",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36"
}]

As you can see, the report is part of an array of reports, since there can be multiple types of reports in a single Reporting API call.

The type we are looking for is csp-violation. Notice the URL that is blocked and the directive that was violated.

Older browsers will generate a report similar to:

{
    "csp-report": {
        "document-uri": "https://test.example.com/",
        "referrer": "",
        "violated-directive": "script-src 'self'",
        "original-policy": "default-src 'self'; img-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp-endpoint; report-uri https://api.example.com/report",
        "disposition": "enforce",
        "blocked-uri": "https://maps.googleapis.com/maps/api/js"
    }
}

Enforcing vs. Reporting

Being able to log your CSP violations is a nice way of analyzing the effectiveness of your CSP, but it also means the browser blocked certain parts of the content. In other words: those user requests caused possible failures in rendering the content correctly!

Wouldn't it be nice if you could check for violations, log them, but still allow the content to be shown to the user? Turns out, you can! By using a content-security-policy-report-only header instead of the content-security-policy header, the browser will only log the violation, but not block any content.

content-security-policy-report-only: default-src 'self'; img-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp-endpoint; report-uri https://api.example.com/report

Now we can collect these reports and log them for further analysis.

Logging API Example

Let's go ahead and set up an API to handle these reports from the browser. The following example was created to be used as an AWS Lambda function that is hooked up to an AWS API Gateway endpoint.

Setting up and mapping Lambda functions goes beyond the scope of this blog post, but there are some good tutorials out there.

Of course, you could also use your own NodeJS Express-based REST API, or any cloud-based service out there. Whatever implementation you choose, be aware that these calls can come from many places (but at least from your website that uses it in its CSP), so don't forget to allow Cross-Origin requests.

exports.handler = async (event, context) => {
  if (event.httpMethod === 'POST' && event.body) {
    try {
      const reqJson = JSON.parse(event.body)
      if (Array.isArray(reqJson)) {
        // this is a Reporting API call, check for csp-violation reports
        for (const report of reqJson) {
          if (report.type === 'csp-violation' && report.body) {
            console.log(JSON.stringify(report.body))
          }
        }
      } else if (reqJson['csp-report']) {
        console.log(JSON.stringify(reqJson['csp-report']))
      }
      return {
        statusCode: 204
      }
    } catch (err) {
      console.log(err.message)
      return {
        statusCode: 400,
        body: JSON.stringify({error: err.message}),
        headers: {
          'Content-Type': 'application/json'
        }
      }
    }
  } else {
    return {
      statusCode: 405,
      body: JSON.stringify({error: 'method not allowed'}),
      headers: {
        'Content-Type': 'application/json'
      }
    }
  }
}

Instead of just logging, we could properly store these reports into a DynamoDB table.

Let's create a cspdata table, sorted by timestamp

Then, let's transform our parsed reports to DynamoDB entries and store them:

const { DynamoDBClient, PutItemCommand } = require('@aws-sdk/client-dynamodb')
const { v4: uuidv4 } = require('uuid');

const client = new DynamoDBClient({
  region: 'eu-west-1'
})

function cspViolationToItem(payload) {
  const item = {
    id: {
      S: uuidv4()
    },
    timestamp: {
      S: new Date().getTime().toString()
    }
  }
  if (payload.documentURL) {
    item.documentURL = { S: payload.documentURL }
  }
  if (payload.blockedURL) {
    item.blockedURL = { S: payload.blockedURL }
  }
  if (payload.effectiveDirective) {
    item.effectiveDirective = { S: payload.effectiveDirective }
  }
  if (payload.disposition) {
    item.disposition = { S: payload.disposition }
  }
  if (payload.originalPolicy) {
    item.originalPolicy = { S: payload.originalPolicy }
  }
  if (payload.referrer) {
    item.referrer = { S: payload.referrer }
  }
  return item
}

function cspReportToItem(payload) {
  const item = {
    id: {
      S: uuidv4()
    },
    timestamp: {
      S: new Date().getTime().toString()
    }
  }
  if (payload['document-uri']) {
    item.documentURL = { S: payload['document-uri'] }
  }
  if (payload['blocked-uri']) {
    item.blockedURL = { S: payload['blocked-uri'] }
  }
  if (payload['effective-directive']) {
    item.effectiveDirective = { S: payload['effective-directive'] }
  }
  if (payload.disposition) {
    item.disposition = { S: payload.disposition }
  }
  if (payload['original-policy']) {
    item.originalPolicy = { S: payload['original-policy'] }
  }
  if (payload.referrer) {
    item.referrer = { S: payload.referrer }
  }
  return item
}

exports.handler = async (event, context) => {
  if (event.httpMethod === 'POST' && event.body) {
    try {
      const reqJson = JSON.parse(event.body)
      if (Array.isArray(reqJson)) {
        for (const report of reqJson) {
          if (report.type === 'csp-violation' && report.body) {
            await client.send(new PutItemCommand({
              TableName: 'cspdata',
              Item: cspViolationToItem(report.body)
            }))
          }
        }
      } else if (reqJson['csp-report']) {
        await client.send(new PutItemCommand({
          TableName: 'cspdata',
          Item: cspReportToItem(reqJson['csp-report'])
        }))
      }
      return {
        statusCode: 204
      }
    } catch (err) {
      console.log(err.message)
      return {
        statusCode: 400,
        body: JSON.stringify({error: err.message}),
        headers: {
          'Content-Type': 'application/json'
        }
      }
    }
  } else {
    return {
      statusCode: 405,
      body: JSON.stringify({error: 'method not allowed'}),
      headers: {
        'Content-Type': 'application/json'
      }
    }
  }
}

Your CSP violation reports will now appear as items in your cspdata table:

From there, you can start analysing violations and refine your CSP before you start enforcing it for all visitors.

Conclusion

As you can see, CSP reporting capabilities allow you to monitor and analyse your content security policies before you start inadvertent blocking of functionality in your website.

As always, we hope you liked this article and if you have anything to add, we are available via our Support Channel.

Keep up-to-date with the latest news