Creating an Electron App - Content-Security-Policy for file://

Creating an Electron App - Content-Security-Policy for file://
Photo by Ilya Pavlov / Unsplash

Content-Security-Policy or short CSP is a concept to prevent certain types of attacks, for example Cross-Site Scripting (XSS). In this post I will explain how I added this kind of security in my app while using the file:// protocol

To enable CSP you need to configure the web sever to return a Content-Security-Policy HTTP header, but what if you don't have a web server?

meta tag to the rescue

<meta http-equiv="Content-Security-Policy" content="....">

Using a meta tag we can supply the policy without passing headers, its that simple.

Here is an example configuration for using webpack:

import CspHtmlWebpackPlugin from "csp-html-webpack-plugin";

plugins: [
            new CspHtmlWebpackPlugin({
                'default-src': ["'self'", "https://example.com"],
                'script-src': ["'self'", "blob:"],
                'style-src': ["'unsafe-inline'", "'self'"],
                'font-src': ["'self'"],
                'connect-src': ["*"],
                'media-src': ["'self'", "data:", "https://example.com"],
                'img-src': ["*", "data:", "blob:"]
            }, {
                nonceEnabled: {
                    'script-src': true,
                    'style-src': false //because of some dynamically loaded css
                }
            })
]
webpack.config.js

nonces

You might have spotted the nonceEnabled part in the code snipped above, nonces are a way to mitigate malicious XSS inline-scripts and some common CSP bypasses.

nonceEnabled adds a random string to the script tags like this:

<script defer src="runtime.js" nonce="2LGxA9H4iu75qCrG1xZ7Ew=="></script><

it also adds this random string to the list of allowed nonces in the CSP header:

... script-src 'self' blob: 'nonce-2LGxA9H4iu75qCrG1xZ7Ew==' ...

Done, right? No more hacks?

Not really, the problem with that is: the nonces are only generated once when webpack compiles the html, if we would compile and ship the app every user would have the same hardcoded CSP header and nonces, this way a bypass would be as easy as finding out the nonce and then injecting a HTML like this:

<iframe srcdoc="<script nonce='2LGxA9H4iu75qCrG1xZ7Ew=='>alert('hacked')</script>"/>

Nonces must be unique for each HTTP response!

How to generate random nonces when using file://

Because we still do not have a web server we have to modify the html before it reaches the browser, luckily electron allows protocol interception:

import { protocol } from 'electron';
import { randomBytes } from "crypto";
import { readFile } from 'fs';
import { extname } from 'path';
import mime from "mime";

//patching nonces

function patchNonces() {
    protocol.interceptBufferProtocol('file', function (request, callback) {
        let parsed = new URL(request.url);
        let result = decodeURIComponent(parsed.pathname);

        // Local files in windows start with slash if no host is given
        // file:///c:/something.html
        if (process.platform === 'win32' && !parsed.host.trim()) {
            result = result.substring(1)
        }

        readFile(result, (err, content) => {
            if (err) throw err;

            let ext = extname(result);
            let data = { data: content, mimeType: mime.getType(ext) };

            if (ext === '.html') {
                //its a html file, replace all nonces with new ones before serving
                let str = content.toString("utf-8");
                let nonces = str.match(/(?<=nonce-)[^']+/g);

                if (nonces) {
                    nonces.forEach(n => {
                        let newNonce = randomBytes(16).toString('base64');
                        str = str.replaceAll(n, newNonce);
                    });

                    data.data = Buffer.from(str);
                }
            }

            callback(data);
        })
    });
}

This way, when the html file is loaded via file:// our piece of code intercepts the request, patches all nonces via regex and returns the new html with new random nonces

Even more security

When using async chunks, webpack loads some parts of the bundle only when necessary, but it loads these parts by injecting script tags to the head, thats why script-src 'self' is necessary.

But even webpack can use nonces, as described here

We just need to make some modifications. First we add a meta tag like this:

<meta data-name="nonce" content="'nonce-webpack'" />

And we modify the webpack.config like this:

'script-src': ["'nonce-webpack'"],

the only script-src allowed is now the webpack nonce and the nonces that are automatically added by html-webpack-plugin

Our nonce-patcher script replaces all nonce-* occurences that are wrapped with single quotes, so "nonce-webpack" will be replaced by the same random nonce in the CSP meta tag and the nonce-webpack meta tag

Now we just need to pass webpack the value of the nonce by getting the content of the meta tag and removing the "nonce-" prefix and single quotes:

//load nonce value from nonce-webpack meta tag
__webpack_nonce__ = document.querySelector("meta[data-name='nonce']").content.replace(/^'nonce-|'$/g, "");

And we're done! Only whitelilsted scripts are loaded.