Chris.luChris.lu header image, displaying an 80s style landscape and sunset

Content Security Policy (CSP)

a female robocop, in front of a police car with the text CSP on the side door, in a futuristic city

Content Security Policy (CSP) is important as it can help you prevent cross-site scripting (XSS), clickjacking, and other code injection attacks resulting from executing malicious content. For example, malicious content can be hidden in remote code from a banner ads service you included in your app, or it could be hidden in code from a client side package you installed, which comes from a compromised NPM account.

For example, using the Content-Security-Policy HTTP header to limit fetching resources only from sources you explicitly declared can mitigate the risks related to content injection attacks. OWASP has an interesting OWASP "Content Security Policy (CSP)" cheat sheet page with examples that show you what kind of malicious attacks the different CSP directives can help prevent

CSP policy using a meta element or header

There are two ways to define CSP directives: either you use a header sent by your server, or you use the meta element in your HTML

A basic meta element to configure a CSP policy would look like this:

<meta
    http-equiv="Content-Security-Policy"
    content="default-src 'self';" />

The header is often preferred because it supports sending reports, which cannot be done using the meta element.

A very basic Content-Security-Policy header looks like this:

Content-Security-Policy: default-src 'self'

Besides the Content-Security-Policy header, there is a second header Content-Security-Policy-Report-Only that can be used, the difference is that when using Content-Security-Policy the browser will enforce the policy, and if you use Content-Security-Policy-Report-Only the browser will only report violations but NOT enforce them

Here is the same example but without enforcing directives (only reporting them):

Content-Security-Policy-Report-Only: default-src 'self'

CSP directives

The default-src directive is a fallback that gets used every time a fetch directive for a resource type is missing. For a complete list, check out the MDN "CSP: default-src" documentation

I saw countless CSP examples (for beginners) that would set the default-src to 'self'. I recommend setting the default-src to none, which will disallow any source for any content type that is not explicitly whitelisted using a fetch directive.

Use a secure by default approach instead of an open everything by default approach, like this:

Content-Security-Policy: default-src 'none'
Warning

Always set a default-src. If you don't set a default-src, then every resource type that has no directive will be allowed, which means that if there is no default-src and you don't set the script-src directive, then all scripts from whatever source will be allowed, if you don't set a default-src there is no fallback.

Now that we have a basic CSP, I would check what violations get listed in your browser console and then use specific directives to fix the violations one at a time. For example, a directive to allow images from the same origin as the HTML document would look like this:

Content-Security-Policy: default-src 'none'; img-src 'self'

For a website that has images, javascript, and CSS and where the javascript code needs to make requests to a remote API (api.example.com), I would use this basic CSP policy:

Content-Security-Policy: default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; connect-src https://api.example.com

Not all policy directives are fetch directives. Check out this MDN "Content-Security-Policy Directives" documentation for a complete list of directives

For example, adding the require-trusted-types-for directive is recommended to reduce the DOM XSS attack surface. This ensures that if you have a script that attempts to put a string into, for example, Element.innerHTML, but the string has not been sanitized, then CSP will consider this a violation:

Content-Security-Policy: default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; connect-src https://api.example.com; require-trusted-types-for 'script'

nonce, hash, and strict-dynamic

An alternative to whitelisting an entire domain for external scripts, is to use hashes or nonces to whitelist only certain scripts from that source.

Those nonces and hashes can also be used for inline scripts. When used for inline scripts, the big advantage is that you can avoid using unsafe-inline, which whitelists all inline scripts.

Hashes are a powerful way of only allowing certain scripts instead of whitelisting an entire external source. This is preferred because if the domain gets taken over or hacked and the script(s) gets altered to include malicious code, the hash won't match anymore, and your code will refuse to use the script. This is much safer than whitelisting a domain and accepting whatever version of a script the domain is hosting.

Nonces are very useful for your inline scripts. Instead of using unsafe-inline, you create a nonce for your inline script(s), hence only allowing scripts that have a nonce you made instead of allowing all inline scripts. Many frameworks support nonces, for example if you use Next.js check out their "nonce" documentation or Remix "Streaming with a Content Security Policy" documentation (If you are using Remix, you might also want to dig into this Remix CSP nonce Issue #5162) and for Astro check out discussion #377

Finally, I recommend checking out the strict-dynamic source expression, which can be used when you want your trust in a script to be propagated to all the scripts getting loaded by the that "root" script. But use with caution as allowing a script to load other scripts should only happen if you have unconditional trust in the source itself, as well as the sources your source gets other scripts from

upgrade-insecure-requests and frame-ancestors

The upgrade-insecure-requests directive can be used to upgrade any first-party as well as third-party requests to https. This is useful for cases where, for example, you have an image URL that has yet to be migrated from HTTP to HTTPS or where a mistake was made by using HTTP instead of HTTPS. This directive will tell the browser to rewrite the URL with HTTPS before the insecure request gets made, but be careful. This is NOT a replacement for the HSTS header.

Here is a quote from MDN that explains it well:

The upgrade-insecure-requests directive will not ensure that users visiting your site via links on third-party sites will be upgraded to HTTPS for the top-level navigation and thus does not replace the Strict-Transport-Security (HSTS) header, which should still be set with an appropriate max-age to ensure that users are not subject to SSL stripping attacks.

So even if you use the upgrade-insecure-requests directive, it is still recommended to also set the Strict-Transport-Security header

You might still find examples on the web using the block-all-mixed-content. This one is, however, deprecated and not needed when using upgrade-insecure-requests

The frame-ancestors directive is useful to specify what parent source may embed a page and can help prevent clickjacking attacks, so if you want to disallow anyone to put your pages into an iFrame then set this directive to none, when frame-ancestors is set to none it does the same thing as the X-Frame-Options header when set to deny, meaning that the frame-ancestors directive is a replacement for X-Frame-Options in modern browsers, so the day you see a high enough support percentage on the caniuse "frame-ancestors" page then you can ditch the X-Frame-Options header

CSP online validation

A tool that can help you improve your Content-Security-Policy HTTP header is Google's CSP Evaluator, it will analyze your settings and make suggestions if it finds something that can be improved

The state of violations reporting (as of February 2024)

CSP violations can not only be seen in the console, as this would limit us to only see violations that occur when we (the developers of the project) visit our project ourselves, but what about violations that happen when a user visits our project, for such cases you can use CSP violations reporting

However, how CSP violation reports work has changed over the years. The W3C has released several iterations of its Content Security Policy recommendations / working drafts and even created a draft for the Reporting API, a new header dedicated to sending reports to an endpoint.

All browsers support the report-uri directive, but (as of now, june 2024) NOT all browsers support the report-to directive or the Report-To and Reporting-Endpoints headers:

report-uri

report-uri is a directive that tells the browser where to send CSP violation reports. The w3c has marked it as deprecated, but as of today, this is still the only reporting mechanism most SAAS logging services support and the only directive supported by all major browsers.

A Content Security header that uses the report-uri directive looks like this:

Content-Security-Policy: ...;
    report-uri https://csp-logging.example.com

report-to != Report-to

If, like me, you read some documents about CSP, you might find that there are two report-to, report-to (with a small first letter r) refers to the directive, this report-to directive can point to a Reporting API header, in the first version (v0) of the reporting API that header was called Report-to (with a capital R)

The report-to directive is part of the w3c CSP level 3 reference and is still valid today

The Report-to header was part of the W3C's initial draft for the reporting API v0. However, this draft is now obsolete. If you see this header in the documentation, it means that it is outdated.

In the new w3c "Reporting API v1" working draft, the Report-to header got replaced by a header that is now called Reporting-Endpoints

Reporting API

The Reporting API describes a new header that tells the browser which endpoint(s) should get used to send the reports.

This header is NOT just for sending CSP violation reports but covers a wide range of use cases. For example, it can be used to send Permissions-Policy reports, reports regarding deprecated browser features your code might be using, reports about browser crashes, and more.

Reporting API v0

The first reporting API v0 draft of the Reporting API (v0) included a new Report-to header

chrome version 69 was the first browser to ship with support for the Report-to header and based on caniuse "Report-to header" neither Firefox nor Safari support it (yet)

An example of the Reporting API v0 (deprecated) using the report-to directive would look like this:

Content-Security-Policy: ...;
    report-uri https://csp-logging.example.com;
    report-to {"group":"default","max_age":10886400,"endpoints":[{"url":"https://csp-logging.example.com"}],"include_subdomains":true}

And using the Report-to header like this:

Content-Security-Policy: ...;
    report-to default
Report-to: {"group":"default","max_age":10886400,"endpoints":[{"url":"https://csp-logging.example.com"}],"include_subdomains":true}

Did you notice the max_age key in the Report-to json? This tells the browser for how long you want to cache the endpoint information, meaning that if the next page (as long as the origin is the same) wants to make a report, you don't need to specify the endpoint again and can just use it in the directive, this feature, however, got dropped in the v1 specs of the reporting API

Reporting API v1

After a while, the w3c team decided to rename the header, and so in the w3c "Reporting API v1" working draft, they renamed the Report-to header to Reporting-Endpoints

chrome 96 is the first browser to ship support for the Reporting-Endpoints header

Note

There is a very good article on developer.chrome.com you may want to read with lots of additional information about the new Reporting API Endpoints titled Monitor your web application with the Reporting API and they also have a Reporting API v0 to v1 migration guide

An example of a header using the new Reporting-Endpoints header for CSP violations:

Content-Security-Policy: ...; report-to default
Reporting-Endpoints: default="https://csp-logging.example.com"

You can specify more than one endpoint if you need to:

Reporting-Endpoints: default="https://csp-logging.example.com", second-endpoint="https://csp-logging2.example.com"

Logging CSP violations

A lot of the paid SAAS Error Monitoring services can also be used to log CSP violations, Sentry.io CSP logging documentation, raygun.com or datadoghq.com

An alternative is to host a logging tool on your own infrastructure. For example, Mozilla published an opensource CSP logging service called "CSP Logger" on GitHub that is written in Javascript, but it only supports the report-uri directive and has not been updated in years

You could also write your own CSP logging endpoint and store the reports in a database, but then you would probably also need to build an admin interface to filter and analyze the reports.

Logging CSP violations using Sentry.io

Sentry.io can be used to log CSP violations and will add those to your project issues.

Sentry.io also supports Certificate Transparency reports logging and HTTP Public Key Pinning (HPKP). However, both those features are now obsolete.

Sentry.io does not have the Reporting-Endpoints header (yet)

The reporting API v1 Reporting-Endpoints header is not yet supported by sentry.io. There are several open tickets related to this; one of them is issue #38940

Sentry.io (by default) has a filter for reports coming from localhost

Your localhost requests might get filtered by Sentry.io if you or a team member have enabled that feature; if you use report-uri, you will see the successful requests in the Network tab, but they won't show up in Sentry.io.

For a guide about how to disable the filter, check out the chapter about disabling the "reports from localhost" filter in my Sentry.io post

Tip

You probably don't want to keep the localhost reports filter disabled for too long as developing locally will generate all sorts of error logs that are not very useful, so just remember to turn the filter back on when you don't need the localhost reports anymore

The Sentry.io documentation example is not ideal

The Sentry.io documentation suggests you add the Reporting API v0 as a fallback for the report-uri directive, like so:

Content-Security-Policy: ...;
    report-uri https://xxx.ingest.sentry.io/api/007/security/?sentry_key=aaa;
    report-to CSP-endpoint
 
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://xxx.ingest.sentry.io/api/007/security/?sentry_key=aaa"}],"include_subdomains":true}

The problem is that Chrome (>96) will attempt to use the new report-to directive, but Chrome (>96) supports the Reporting-Endpoints header from the Reporting API v1 and not the Report-To header defined in Reporting API v0 specs. Chrome will fail to send the reports and never fall back to using the report-uri directive, meaning that if you use this suggested setup, you will not get any reports from Chrome (>96).

I suggest using the report-uri directive for the time being and adding the report-to directive only when browser support for that feature has significantly improved.

CSP debugging tips

Reduce chrome CSP reporting delay

Chrome will wait some time to ensure it has collected all the potential CSP reports before attempting to send them to the reporting server. However, there is a command-line switch that you can use to shorten that delay when debugging CSP reporting.

Warning

This option is NOT a Flag. It is a command-line option.

On Mac or Linux, to shorten this delay to a minimum, you can use the following command in your terminal to start Chrome manually:

PATH/TO/Chrome --short-reporting-delay

On Windows, if you use PowerShell, then the command syntax is a bit more complex:

Start-Process -FilePath 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe' -ArgumentList '--short-reporting-delay'

report-uri works in localhost, but report-to does NOT

When using report-uri, reporting violations work locally (localhost) as well as in preview/production (secure context / https URL)

When using report-to, reporting violations will NOT work on localhost (in Chrome) without a valid SSL certificate, which is surprising as localhost usually is considered a secure context (if you know why, please let me know using the chris.lu github discussions)

In staging (preview) / production environments, however, the report-to will work if those environments have an SSL certificate.

report-uri requests show up in the Network tab

When using the report-uri directive, you can inspect requests using the developer tools Network tab like any other POST request made by your app.

report-to requests are Not in the Network but in the Application tab

When using the report-to directive, you won't see any entries in your developer tools Network tab. This is because a background service makes the request(s) and not the website (your code)

To see the requests, open your developer tools, then click on the Application tab, and then on the left list under Background services, click on Reporting API:

Adblockers may block CSP violation logging services

If you are using report-uri and your logging tool does not receive the reports, then they might be blocked. To verify if the requests are blocked, open the developer tools in your browser and then go into the network tab. Look at the request rows and check if their status is blocked. In this case, you probably have an adblocker extension installed that blocks the requests. I, for example, use Privacy Badger and have to allow the domain (of the service I send reports to) in the extension settings

So make sure to either whitelist the Sentry.io CSP reporting URL or disable the ad blocker for your domain, or even disable it completely until you are done testing.

Chrome (Chromium) CSP preflight request headers BUG

This bug is important if you want to create your own CSP logging server.

A bug in Chrome (Chromium) will cause Preflight requests to fail if some of the response headers use a wildcard.

Chrome will send a request to your server using the OPTIONS method to test your endpoint before sending the actual reports, but if your server response has headers with an Asterisk, then the preflight request will fail.

So, for example, these headers will NOT work:

headers["Access-Control-Allow-Origin"] = "*";
responseHeaders["Access-Control-Allow-Methods"] = "*";
responseHeaders["Access-Control-Allow-Headers"] = "*";

But these will work:

headers["Access-Control-Allow-Origin"] = request.headers.origin;
responseHeaders["Access-Control-Allow-Methods"] = "POST";
responseHeaders["Access-Control-Allow-Headers"] = "Content-Type";

Have a look at the chromium issue #40733909 if you want more details, and maybe you are lucky, and it got fixed

Create a quick and dirty CSP reporting server

To help me better understand what kind of information browsers send when using the different versions of CSP reports, I wrote a small Node.js server tool to display some information about CSP logging calls.

If you want to set up a CSP logging server for yourself, you can use the following code to get started:

csp-logging.mjs
import { createServer } from 'node:http';
import url from 'node:url';
 
// https://nodejs.org/en/learn/modules/anatomy-of-an-http-transaction
 
const server = createServer((request, response) => {
 
    const { headers, method, url: requestUrl } = request;
    const parsedUrl = url.parse(requestUrl, true);
    const query = parsedUrl.query;
    const path = parsedUrl.pathname;
 
    console.log('parsedUrl: ', parsedUrl);
    console.log('query: ', query);
    console.log('path: ', path);
    console.log('method: ', method);
    console.log('headers: ', headers);
 
    if (method === 'OPTIONS') {
        const responseHeaders = {};
        // TODO: check if origin is in whitelist
        headers["Access-Control-Allow-Origin"] = request.headers.origin;
        responseHeaders["Access-Control-Allow-Methods"] = "POST";
        responseHeaders["Access-Control-Allow-Credentials"] = false;
        //headers["Access-Control-Max-Age"] = '86400'; // 24 hours
        responseHeaders["Access-Control-Max-Age"] = '300'; // 5 minutes (for testing)
        responseHeaders["Access-Control-Allow-Headers"] = "X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept";
        response.writeHead(200, responseHeaders);
        response.end();
    } else {
 
        let body = [];
 
        request
            .on('error', err => {
                console.error(err);
            })
            .on('data', chunk => {
                body.push(chunk);
            })
            .on('end', () => {
                body = Buffer.concat(body).toString();
                console.log('body: ', body);
            });
 
        //response.writeHead(200, { 'Content-Type': 'text/plain' });
        //response.end('Hello World!\n');
 
        response.statusCode = 200;
        response.end();
    }
 
});
 
// starts a simple http server locally on port 3000
server.listen(3000, '127.0.0.1', () => {
    console.log('Listening on 127.0.0.1:3000');
});

To start the server, use the following command:

node csp-logging.mjs

Debugging Sentry.io dropping reports

While testing the CSP reporting feature on Sentry.io, I had trouble understanding why some reports would not go through.

In the Stats page on Sentry.io, I would only see that a certain number of requests had been dropped, but I would not know why.

If you want to get a little bit more information as to why requests get dropped, you can use the Sentry.io API. I recommend having a look at the Sentry.io API chapter in my Sentry.io Post

Debugging Chrome requests using Netlog

Warning

Before you start experimenting with Chrome Netlog, know that, as of now, the information you get from the logs is not very helpful. You still can't see the exact reason as to why a CSP report call has failed, but hopefully, future versions will bring improvements. This is why I added this option to this post.

Debugging the reporting API in Chrome is painful as you don't get any information about why requests have failed. If they fail, The requests will return to a Queued state, and Chrome will retry to send the report. If sending the reports fails for a while, chrome will show a "MarkedForRemoval" state

Chrome (Chromium) has a tool called Netlog:

NetLog is an event-logging mechanism for Chrome’s network stack to help debug problems

You can access Netlog by entering the following URL into your address bar:

chrome://net-export/

Now click on the Start Logging To Disk button, and then in another tab, open the website, making the CSP reporting calls.

Then, after a while, click on Stop Logging.

To analyze the content of the log file, head over to the Netlog viewer website that Google has created to help you analyze logs

Then, in the left navigation, click on Reporting.