When it comes to web security, XSS is one of the most common vulnerabilities to find. It packs a big punch, making it possible for hackers to deface web pages, steal cookies, and more. A classic attack, XSS has done far better than its peers, such as SQLi, in terms of remaining popular and easy to perform. Of course, web browsers have done everything they can to make this attack harder. And the top technique for preventing XSS is something called a Content Security Policy, or CSP. As the XSS vs CSP battle rages on, this guide will give you a practical, hands on view of how CSPs fight XSS, and how hackers get around them anyway.
Although the content below aims at a beginner audience, it does also presuppose some technical knowledge. Specifically, you’ll want to have a basic understanding of Javascript and Cross Site Scripting.
If you lack that knowledge, I highly recommend that you check out the two excellent resources listed below.
Once you feel ready, you can check out challenge sites that let you practice these skills. HackThisSite, for example, offers these kinds of challenges.
Blocking XSS with a real CSP
To start with, we’ll imagine the oldest trick in the book. Imagine a chat app that allows users to include images. To allow images, the site simply allows you to post raw, unsanitized HTML code. The site for this app looks like this:
<style>
#main {
height: 300px;
width: 350px;
margin: auto;
border: 1px solid black;
padding: 5px;
}
#items {
width: 150px;
margin: auto;
margin-bottom: 10px;
}
</style>
<div>
<div id="main">
<center><h2>TODO LIST</h2></center>
<div id="items">
</div>
<center>
<input type="text" id="todo" />
<input type="button" value="Add item" onclick="addItem()" />
</center>
</div>
</div>
<script>
items = {}
const addItem = () => {
const currentItemsElement = document.getElementById('items');
const items = currentItemsElement
.innerText
.split('\n')
.map(e => e.slice(2));
const newItem = '* ' + document.getElementById('todo').value;
currentItemsElement.innerHTML = items
.map(e => '* ' + e)
.concat(newItem)
.join('<br/>')
.slice(1)
}
</script>
Running that code in the browser, it renders like so…

It’s obviously very simple, but it works. Now, think of what would happen if a hacker were to use the onerror attribute of image tags to execute Javascript code. For example, someone could submit a message containing code like this:
<img src="lol" onerror="alert('HAXXED')"></img>
If they do so, the Javascript runs in the browser!

Because we render this using innerHTML, the onerror attribute runs and executes the user-supplied Javascript. We can prevent this by only allowing Javascript loaded from an external file. Of course, we’ll also have to move our JS code to an external file! We’ll specify that only JS files from the domain we’re currently on can load. Here’s how the code looks now:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<link rel="stylesheet" href="style.css">
<div id="main">
<center><h2>TODO LIST</h2></center>
<div id="items">
</div>
<center>
<input type="text" id="todo" />
<input id="add-item" type="button" value="Add item" />
</center>
</div>
<script src="script.js"></script>
If I try to run the same attack as before, it doesn’t work. Instead, the Javascript console in the browser issues an error.
Refused to execute inline event handler because it violates the following Content Security Policy directive: "default-src 'self'".
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.
Great! The browser recognized our CSP, and blocked the attack correctly!
Preventing advanced XSS
If only the CSP vs XSS battle were as simple as what we saw above!
Our Content Security Policy is still pretty simple. And more importantly, it’s possible for attackers to launch an XSS attack by bypassing this CSP! For example, suppose we had an app where users could upload files. For example, an imageboard like Lainchan. The attacker then uploads their malicious Javascript file. Now additionally imagine that a <script> tag allows the user to select which JS file to load using a user-controlled variable.
The browser would run the malicious Javascript page!
And there are more possible scenarios. What if you convince the backend to load a <script> tag that loads your user-uploaded JS file?
Well, how would we prevent that? As it so happens, CSPs provide a mechanism to deal with that. Specifically, you can provide a nonce, which is a token that your app uses to prove to the browser that a <script> tag should load. Here’s how it works:
- The backend attaches a nonce to each <script> tag it wants to render on the page.
- The CSP specifies which nonces are valid.
- An attackers <script> tag won’t have a valid nonce, and so the browser will reject it.
So from a dev’s viewpoint, a CSP can thwart an attacker. But to show the limitations of this approach, let’s create a script that a nonce couldn’t help with!
Concocting our vulnerable CSP vs XSS app
First, we need an API endpoint to handle file uploads (which the attacker will abuse to upload their malicious Javascript file) and save the uploaded file locally:
const express = require('express');
const multer = require('multer');
const app = express();
const port = 3000;
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // Specify the folder where you want to save the files
},
filename: (req, file, cb) => {
cb(null, file.originalname);
},
});
const upload = multer({ storage: storage });
app.post('/upload', upload.single('file'), (req, res) => {
res.send('File uploaded successfully!');
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
A good start! Now we’ll add another endpoint, that serves an HTML file where a variable (controlled by the user!) allows an attacker to control the URL of the script. Note that only scripts served on this same domain will work, because we’ll have a CSP set to default-src: ‘self‘. Okay, so on to the second endpoint! This is where the meat and potatoes of the vulnerability allow attackers to insert their maliciously uploaded code.
app.get('/index', (req, res) => {
const js = req.query.js;
res.end(```
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<script src='${js}'></script>
<h1>Select either the darkmode or lightmode JS file to browse...</h1>
```)
});
Okay, so a user-controlled variable decides whether the app is in light mode or dark mode, by simply providing the location of which script to use. In practice, this would be done by frontend code, not by the user manually.
Anyway, with this, we should be able to launch our attack! First, we create a Javascript file containing some minor code to verify that our attack works. I’ll add the code alert(“HACKED!!!!!”) to a file called evil_code.js. With that done, we can upload the file to our API using curl.
$ noncified cat evil_code.js
alert("HACKED!!!!!")
$ curl -X POST -H "Content-Type: multipart/form-data" -F "file=@evil_code.js" http://localhost:3000/upload
File uploaded successfully!
Now for the coup de grace – we load the index, pointing the js query parameter to our “evil” file.

Viola! Once again, the attacker bypassed our CSP! The point is – even as defensive tech grows, clever hackers will find ways to make sure the CSP vs XSS battle doesn’t end anytime soon.
CSP vs XSS: Learn more
CSPs do way more than prevent XSS. In fact, they protect browsers from a huge range of attacks, such as clickjacking, CSS injection, IP leaks, and much more. To get a broader sense of what you, as a security engineer or webmaster, can do with your site’s CSP, check out MDN’s excellent docs on the subject: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP.
From a pentester’s viewpoint, CSPs present quite a challenge. Still, red teamers have devised tactics for getting around these pesky defenses. For example, check out PortSwigger’s XSS lab on bypassing a CSP: https://portswigger.net/web-security/cross-site-scripting/content-security-policy/lab-csp-bypass. Or for a more complex (and I would argue, realistic) example: https://hurricanelabs.com/blog/bypassing-csp-with-jsonp-endpoints/.
If you go through the contents those links, you’ll likely notice that bypassing a CSP becomes complicated, quick. It’s similar in many respects to bypassing a WAF (web application firewall). The best way to practice is to simply setup different CSPs and devise ideas for getting around them. If you need inspiration, read CSP bypass writeups on HackerOne and other platforms.
The classic battle between CSP vs XSS rages on. Whether a new bypass means a headache or opportunity depends on whether you work in blue or red teams, but either way, almost no one is neutral!
Leave a Reply