Creating an Electron App - Content-Security-Policy for file://
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:
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.