Content Security Policy – Script-Src

This blog post is going to summarize the available options (values) for the ‘script-src’ directive within the Content Security Policy (CSP) header. The CSP should be configured from a security standpoint such that it bolsters the security posture of your website. A strong CSP provides solid defenses against attacks such as Cross Site Scripting (XSS) and helps ensure (as a defender) you know exactly what scripted content is allowed to run on your web services. A strong CSP can even help drive compliance such as with the PCI DSS 4.0 requirements around strictly managing your script inventory on payment pages.

I wanted to lab out a bunch of scenarios specifically around the ‘script-src’ directive and document exactly how the various configurations behave. This post is more for reference for me, but sharing in the event others find it useful. Walking through tangible examples makes it ‘real’ and helps me retain the information.

Here we go!

Reference Docs: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

  • Content Security Policy – Header
  • script-src – Directive within the CSP header
  • ‘self’, hashes, nonce, URLs, etc. – values used within the directive

Let’s go through all of the options and see examples

CSP Script-Src Options

For labbing this out, I created a setup primarily using Cloudflare capabilities. Setup:

Note: I keep my lab content gated (off and on) so you will probably get a 403 if you try to get at this content – it’s also entirely possible I have since destroyed these pages and workers. I used Cloudflare because I needed to be able to use full domains and subdomains to demonstrate the capabilities in a more meaningful way. I will be tweaking the main page, the inline scripts, the 1st and 3rd party scripts, as well as the CSP to demo the concepts.

Initially, the cptestscript.js on both simply do a console.log reflecting their identity.

Subdomain script – console logs that the script is from a subdomain:

Cloudflare worker code with a simple console.log

1st party script – console logs that the script is delivered from the primary domain: 

Cloudflare worker code with a simple console.log

Fancy, right?

The page just loads both of the scripts via a <script src=…> tag in the header.

HTML code showing two simple <script> tags

Let’s dig into the script-src options.

Looking for a specific value to be used within the directive? Jump ahead:

None – The Strictest Setting:

The initial CSP I am going to start with is this:

Worker code setting the script-src directive in the CSP (Cloudflare)

With that, we have no scripts being allowed from either inline, 1st, or 3rd parties. Here we can see the CSP as part of the response in Burp (love Burp):

Burp window showing the CSP header

In the browser, neither script is allowed to execute:

Browser console errors showing no JS code execution

Cloudflare forces their beacon script in there – it also fails. We should also test inline scripts – I added the following to my HTML:

Simple <script> tag with console.log

And now I get this error as well: 

Browser console error for inline script

Not even JS code within the page (inline) is allowed to execute! This is the strictest possible setting – essentially we don’t have any JS being able to execute on page.

Self:

This is less strict – now we at least have some code execution.

‘self’: Allows loading scripts from the same origin (domain).

Reloading the page, I see I catch the new CSP:

Burp response showing CSP

And, now I only have 3 errors in the console! One of them is the Cloudflare script and the other two are the 3rd party/subdomain script and the inline script: 

Console errors but with primary domain execution showing

We can even see the console.log message stating that the script being served from the 1st party domain is being allowed to execute.

Specific URIs/URLs:

Allows loading scripts only from specified domains (e.g., ‘https://example.com’, or a subdomain).

Let’s use this to allow 1st and 3rd party scripts to execute on the page. We can leave ‘self’ as part of the CSP and specify https://assets.jhgfdsa.com as the 3rd party (subdomains are not self and thus fall under 3rd party).

Code for setting the CSP header

With a reload, I can see that new CSP:

Burp window showing new CSP in place

Looking at the console errors, I now see only 2 – one being that Cloudflare script and the other being the inline.

Console window showing execution from both first and third party locations

We can also see that both the 1st and 3rd party scripts were able to successfully execute and log to the console.

Wildcards:

*.example.com: Allows scripts from all subdomains of example.com.

Interestingly, this only works with subdomains. There is no way to wildcard both https://jhgfdsa.com and https://assets.jhgfdsa.com together.

Let’s test with a wildcard CSP:

New CSP code

Notice that ‘self’ is no longer present. Reloading the page and we can see the CSP:

Burp showing the new CSP

Based on this, I would expect any script asset loaded from https://assets.jhgfdsa.com to work (or from any subdomain of .jhgfdsa.com)

Browser console showing only subdmain/third party code execution

And that is exactly what we have – we now only have the script from the 3rd party/subdomain executing within the browser.

Note: this example above really is not best practice. It is much better to specify the protocol along with the wildcard domain – https://*.jhgfdsa.com. Without the protocol, both HTTP and HTTPS are allowed (it does not allow other protocols – just these two) and it is best to lock it down to just HTTPS if possible.

Unsafe Inline:

‘unsafe-inline’: Allows the execution of inline scripts (e.g., <script> tags with JavaScript content or onclick attributes).

Using this isn’t a great idea but sometimes it is the only option. Unfortunately for a defender (great for an attacker) is this opens the door for arbitrary code and XSS. It will, however, allow code between <script> tags to execute just fine. Let’s drop it in and see what we get.

New CSP:

New CSP code

We see the new CSP in Burp:

Burp window showing the CSP header

And, we can see that the inline script is now allowed to execute!

Browser console shwoing that inline scripts work

We’ll dig into better ways to do this with the next two options.

Nonce:

‘nonce-<random_value>’: Allows scripts that have a nonce attribute matching the specified value.

This one is a bit harder to repro in a meaningful way, however, I was able to reconstruct the CSP Cloudflare worker to do the work. Previously, the worker was only responsible for setting the CSP. Now, it generates a nonce value, inserts it into the CSP and it replaces the simple script tag within the HTML document so that it includes the nonce value.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const response = await fetch(request);
  const newHeaders = new Headers(response.headers);

  // Generate a random nonce value
  const nonce = crypto.randomUUID(); // Generates a UUID as a nonce

  // Set the CSP header with the generated nonce
  newHeaders.set('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}';`);

  // Get the response body as text to modify it
  let text = await response.text();

  // Insert the nonce into the specific inline script
  text = text.replace(
    '<script>console.log("Inline scripts work!");</script>',
    `<script nonce="${nonce}">console.log("Inline scripts work!");</script>`
  );

  return new Response(text, {
    status: response.status,
    statusText: response.statusText,
    headers: newHeaders,
  });
}

Now, let’s reload and see what we get.

Browser console shwoing that primary domain and inline scripts work

The CSP includes both the nonce for the inline script as well as ‘self’ so we see both executing. This is great! Now, let’s add an additional inline script to ensure it is not allowed.

Browser console showing only the primary domain and first inline script works

Fantastic – the script with the nonce value is allowed to execute while the other inline script is not allowed via the CSP.

Can the same nonce value be used more than once? A simple update of the Cloudflare worker code to do a replace on both scripts:

Cloudflare worker code showing the <script> tag replacement for both tags applying the nonce

yields that both scripts can be executed with the same nonce!

Browser console showing both inline scripts work

Nonce values are strictly for inline scripts – they cannot be used with 1st or 3rd party scripts.

Hashes:

Let’s rip the nonce value out and use hashes instead.

‘sha256-<base64-value>’, ‘sha384-<base64-value>’, ‘sha512-<base64-value>’: Allows scripts that match the specified hash of the content.

How is the SHA value calculated?

<script>console.log(“Hello World!”); </script>

Notice the space between the semicolon and the closing script tag. The code within the script tags (including spaces) needs to be captured exactly. Using any one of a myriad of online tools (or offline), here is the base64 encoded sha256 value for our console.log above:

echo -n ‘console.log(“Hello World!”); ‘ | openssl dgst -sha256 -binary | base64

> DvL73Hb8InRjOEVBCOqargqEGSMDYUQ0PhgAt+WcBgE=

Let’s revert the CSP to before the nonce value was in place and put the SHA value along with ‘self’ into the CSP:

New CSP code in worker

Then, I updated the second <script> tag in the page code to have the <script> tag as above – except without the extra space after the semicolon. Reloading the page we can see the new CSP:

Burp window showing the CSP header

For errors, we can see the following:

Only primary domain allowed

And this makes sense. The script delivered from the 1st party domain is the only one that is allowed to execute. 3rd party/subdomain is blocked (did not include https://assets.jhgfdsa.com) and both of the inline scripts (the old and the new) were prevented from executing. Now, lets add the space back into the code between the semicolon and the closing script tag. This should now allow that chunk of inline code to execute:

Primary domain and new inline script with hash allowed to work

We have a match! The second <script> tag is now allowed to execute.

Hashes are for inline scripts only. To ensure the integrity of a 1st or 3rd party script, use an SRI value instead. Keep in mind that any leading or trailing spaces will have to be captured and used in the calculation of the SHA value. Also take note that it is the base64 encoded SHA value – not just the SHA value itself.

Note: this does not allow for the hashing and use of inline event handlers like this:

<img src=1 onerror=alert,1 />

The error event handler code cannot be hashed and included in the CSP. See unsafe-hashes later in the post to allow inline event handler code to execute.

Strict Dynamic:

‘strict-dynamic’: When used with a CSP nonce or hash, this allows scripts loaded by trusted scripts while ignoring other script-src rules.

What this really does is all of the scripts loaded into the page need to use a nonce or hash value which means they have to be inline. No scripts loaded from 1st or 3rd party sources are allowed in the original page source. Only scripted inline content with a hash or nonce will be allowed per the CSP.

What strict-dynamic also does is it allows the now trusted scripts to load additional scripts. So, if were to provide a nonce for an inline script, it could inject an additional DOM <script> elements for the 1st or 3rd party scripts. For example, an inline script could inject the 3rd party script (from https://assets.jhgfdsa.com or anywhere else) and that content would be allowed to execute as well.

<script nonce="randomvaluenonce">
const script = document.createElement('script');
script.src = 'https://assets.jhgfdsa.com/js/csptestscript.js';
document.head.appendChild(script);
</script>

I’ve actually seen where strict-dynamic was applied and GTM (Google Tag Manager) was allowed on page with a hash. In reality, this makes no sense. The GTM code stays relatively static and only the GTM (and inline code with nonce) was allowed to execute. The problem was that a team outside of security controlled GTM and was loading 3rd party marketing scripts and pixels via the tag manager. Due to strict-dynamic, trust was propagated to all of the 3rd party scripts loaded via GTM.

Unsafe Eval:

‘unsafe-eval’: Allows the use of eval(), new Function(), and similar constructs. The risk here is that these pieces of functionality allow the code to take a string and turn it into executable code.

unsafe-eval in CSP allows the execution of dynamically generated JavaScript code, such as code passed to eval(), new Function(), or string arguments in setTimeout() and setInterval(). This greatly weakens security by increasing the risk of cross-site scripting (XSS) attacks.

Here are functions impacted by the ‘unsafe-eval’ value for the script-src directive within a CSP:

  • eval()
  • Function contruction (e.g., new Function())
  • setTimeout() with string arguments
  • setInterval() with string arguments
  • Worker or Shared Worker with blob URLs containing potentially executable code

So, any code that tries to execute any of these functionalities will be blocked (or modified) by the CSP. Let’s look at examples for each.

‘unsafe-eval’ with eval()

First off, eval() is just dangerous. Enabling ‘unsafe-eval’ within the directive creates significant vulnerabilities to XSS attacks—any instance where user input flows into eval() becomes a potential attack vector. Additionally, it introduces performance issues, as code executed through eval() forces the JavaScript interpreter to reprocess the code dynamically.

With that being said, here’s an inline example. Let’s update the page to have the following code:

let code = ‘console.log(“Hello World!”);’;
eval(code);

Let’s run it with the following CSP (eval() still not allowed):

New CSP with unsafe-inline

And we get:

Browser console showing eval is not allowed

So, the first inline script worked, however, the second inline script (responsible for Hello World!) was not allowed to execute. Note the second error – the eval function is not allowed to run since unsafe-eval was not specified in the CSP. Let’s add it and try again.

CSP now including unsafe-eval

And we get:

Unsafe-eval script now writes to console

We successfully executed that code that was held in a variable via the eval function. This can be very dangerous – if evals are in place strings can be turned into executable JS and it is imperative user input is never passed through the function.

Let’s try it both the 1st and 3rd party scripts. I am simply going to move the existing console.logs into an eval as in the example above. Here’s the starter CSP:

New CSP with first and third party domains

I am going to temporarily comment out the inline scripts within the page to minimize the errors. With both external scripts updated, here is what we get in the browser now:

Browser console showing eval not allowed from first and third party domains

Neither the 1st or 3rd party scripts are allowed to execute due to the eval function being present. Let’s add unsafe-eval to the CSP and reload:

Burp window showing the CSP header

In the browser we see execution: 

Browser console now showing the unsafe evals are allowed to execute

Unsafe-eval allows for the use of the Eval function across inline, 1st, and 3rd party scripts. It allows a string to be turned into executable code.

‘unsafe-eval’ with Function()

This is the same as above. Rather than simply taking and passing a string into eval(), a string can be turned into a function.

let functionCode = “console.log(‘Result string of a new function’)”;
let myFunction = new Function(functionCode);
myFunction();

Again, a string is turned into executable code. Placing this code directly into the page and allowing both ‘unsafe-inline’ and ‘unsafe-eval’ gives us this:

Browser console showing the unsafe-eval for a new Function

‘unsafe-eval’ with setTimeout() or setInterval()

Same thing here – both setTimeout() and setInterval() have the ability to execute a string as code. Without the ‘unsafe-eval’ in the script-src directive, a string will be treated as a string. With ‘unsafe’eval’ present, the functions will attempt to execute the string as code.

setTimeout(“console.log(‘setTimeout – logged to console’)”, 1000);
setInterval(“console.log(‘setInterval – logged to console’)”, 1000);

This has been placed into the page. It works the same regardless of whether this is placed inline, 1st party, or 3rd party.

Here’s the CSP (no unsafe-eval):

Burp window showing the CSP header

With that, here’s what we get in the browser:

Browser console showing all scripts blocked

Everything is blocked. Without ‘unsafe-eval’, the new inline scripts are prevented from executing. Since setTimeout() and setInterval() are expecting function calls in the first parameter position they are not able to execute. Unsafe-eval is required to allow these functions to accept a string in the first position.

Updated CSP:

Updated CSP with unsafe-eval

And now, here is the browser result:

Browser console showing the console.log results of setTimeout and setInterval

Both functions are now able to turn the string into executable code. Again, not a best practice and try to avoid.

‘unsafe-eval’ with Worker or Shared Worker

Worker and SharedWorker are constructs in JavaScript that allow you to run scripts in background threads, separate from the main browser thread. The goal is to accomplish complex tasks without blocking the main thread when using workers.

The same risks for converting string content into executable code exists with both Workers and SharedWorkers.

let code = “console.log(‘Worker console.log’)”;
let blob = new Blob([code], { type: ‘application/javascript’ });
const worker = new Worker(URL.createObjectURL(blob));

This code will create a worker on a separate thread that calls the console.log. Let’s give it a go without unsafe-eval in place.

CSP (no unsafe-eval):

Burp window showing the CSP header

And we get:

Browser console no JS allowed to execute

A key note here – rather than using the script-src directive, a worker-src directive would take precedence. Important to pay attention here to ensure you are securing the worker code correctly.

Let’s update the CSP to include unsafe-eval and worker-src blob:

Burp window showing the CSP header

And now we have the worker being able to write the console:

Worker console.log allowed to execute

There are essentially 3 different types of workers to consider here – blob workers (as seen above), module workers (JavaScript modules based which enables ES6 import/export functionality), and URL based workers. For the first two, the CSP as seen above is applicable. For URL based, the CSP as seen above works the same EXCEPT (and this is a big one) the CSP in play is the CSP that gets delivered when the worker retrieves the code from the URL. Make sure you trust the incoming code.

Allowlist Schemes:

http:, https:, data:, blob:: Allows scripts from specific URI schemes (e.g., https: for secure scripts).

This could be a whole blog post by itself (and maybe it will be). Essentially, this is another mechanism to allow different types of content be used to drive scripted content on the site. Most commonly, https: will be in place. Common schemes are:

  • https
  • wss (secure web sockets)
  • blob (for workers)
  • data (inline data)
  • filesystem (scripts stored in the browsers filesystem API)
  • ws (unsecure web sockets)
  • http (unsecure http)

There are many others that can be in play. Example CSP allowing the data scheme:

Burp window showing the CSP header

Report URI:

‘report-uri’: Allows violation reports to include a sample of the blocked script. This will send a report to a URL specified within the CSP for the purposes of debugging.

Example CSP:

Burp window showing the CSP header

Now, I don’t have this endpoint available – but that’s ok. We can see the attempted reporting of CSP violations to the endpoint.

Browser console errors and attempted report-url calls

Here we see in the console four CSP violations and four attempted reports. Looking in Burp, we can see the payloads for the attempted reports:

Burp window showing the payload of the attempted calls to the report-uri

The report URL can be 1st or 3rd party. This can be great for troubleshooting, however, watch for accidental data exposure.

Unsafe Hashes (Newer Browsers Only):

‘unsafe-hashes’: Permits the use of event handlers and other inline code using hashes. This means you would no longer need to include ‘unsafe-inline’ to use event handlers like this:

<img src=1 onerror=alert,1 />

In this case, the hash of the ‘alert,1’ would need to be included in the CSP to allow it to run. This is great as it allows granular control over which scripted content is allowed to run. This is not so great from an administrative standpoint – imagine having to hash all of these event handlers and place them in the CSP and then maintain that. For some sites – no problem. For others, this would be awful.

Summary

So there you have it—the ins and outs of CSP’s script-src directive. For bug bounty and defenders alike, it’s important to understand the power and capabilities of a well configured CSP. By tightening up script-src with things like nonces, hashes, and strict sourcing, defenders can seriously cut down on XSS risks. For bug bounty, finding weaknesses in the CSP can be a key to a nice pay day.

Happy Hacking!

FAQ

Q: Can I use Content Security Policy (CSP) to allow specific scripts based on their file paths (e.g., https://example.com/js/script1.js), but block others (e.g., https://example.com/js/script2.js)?

A: No, CSP script-src directives do not support allowing or blocking individual scripts based on their specific file paths. The policy can only control scripts based on domains, protocols, and ports. For example, if you allow https://example.com in a script-src directive, it permits all scripts from that domain, including any file paths under it. If you need more granular control over specific scripts, you would have to consider using server-side logic or other security mechanisms beyond CSP.

Leave a Reply