Skip to content
This repository has been archived by the owner on Jan 11, 2023. It is now read-only.

Commit

Permalink
Allow scripts to contain a style CSP-nonce
Browse files Browse the repository at this point in the history
This follows on from e377515 which
introduced a script nonce. The same nonce is now used for the inline
styles, allowing a stronger CSP (nonce over unsafe-inline).

This also includes a test for the existing script nonce.
  • Loading branch information
pgjones committed Sep 22, 2020
1 parent 7eac08e commit 047b1c9
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 4 deletions.
2 changes: 1 addition & 1 deletion runtime/src/server/middleware/get_page_handler.ts
Expand Up @@ -341,7 +341,7 @@ export function get_page_handler(
.map(href => `<link rel="stylesheet" href="client/${href}">`)
.join('');
} else {
styles = (css && css.code ? `<style>${css.code}</style>` : '');
styles = (css && css.code ? `<style${nonce_attr}>${css.code}</style>` : '');
}

const body = template()
Expand Down
6 changes: 3 additions & 3 deletions site/content/docs/12-security.md
Expand Up @@ -6,9 +6,9 @@ By default, Sapper does not add security headers to your app, but you may add th

### Content Security Policy (CSP)

Sapper generates inline `<script>`s, which can fail to execute if [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers disallow arbitrary script execution (`unsafe-inline`).
Sapper generates inline `<script>`s and `<style>`s, which can fail to execute if [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers do not allow javascript or stylesheets sourced from inline resources.

To work around this, Sapper can inject a [nonce](https://www.troyhunt.com/locking-down-your-website-scripts-with-csp-hashes-nonces-and-report-uri/) which can be configured with middleware to emit the proper CSP headers. Here is an example using [Express][] and [Helmet][]:
To work around this, Sapper can inject a [nonce](https://www.troyhunt.com/locking-down-your-website-scripts-with-csp-hashes-nonces-and-report-uri/) which can be configured with middleware to emit the proper CSP headers. The nonce will be applied to the inline `<script>`s and `<style>`s. Here is an example using [Express][] and [Helmet][]:

```js
// server.js
Expand Down Expand Up @@ -36,4 +36,4 @@ Using `res.locals.nonce` in this way follows the convention set by
[Helmet's CSP docs](https://helmetjs.github.io/docs/csp/#generating-nonces).

[Express]: https://expressjs.com/
[Helmet]: https://helmetjs.github.io/
[Helmet]: https://helmetjs.github.io/
58 changes: 58 additions & 0 deletions test/apps/csp-nonce/rollup.config.js
@@ -0,0 +1,58 @@
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import svelte from 'rollup-plugin-svelte';

const mode = process.env.NODE_ENV;
const dev = mode === 'development';

const config = require('../../../config/rollup.js');

export default {
client: {
input: config.client.input(),
output: config.client.output(),
plugins: [
replace({
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode)
}),
svelte({
dev,
hydratable: true,
emitCss: false
}),
resolve()
]
},

server: {
input: config.server.input(),
output: config.server.output(),
plugins: [
replace({
'process.browser': false,
'process.env.NODE_ENV': JSON.stringify(mode)
}),
svelte({
generate: 'ssr',
dev
}),
resolve({
preferBuiltins: true
})
],
external: ['sirv', 'polka']
},

serviceworker: {
input: config.serviceworker.input(),
output: config.serviceworker.output(),
plugins: [
resolve(),
replace({
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode)
})
]
}
};
9 changes: 9 additions & 0 deletions test/apps/csp-nonce/src/client.js
@@ -0,0 +1,9 @@
import * as sapper from '@sapper/app';

window.start = () => sapper.start({
target: document.querySelector('#sapper')
});

window.prefetchRoutes = () => sapper.prefetchRoutes();
window.prefetch = href => sapper.prefetch(href);
window.goto = href => sapper.goto(href);
3 changes: 3 additions & 0 deletions test/apps/csp-nonce/src/routes/_error.svelte
@@ -0,0 +1,3 @@
<h1>{status}</h1>

<p>{error.message}</p>
7 changes: 7 additions & 0 deletions test/apps/csp-nonce/src/routes/index.svelte
@@ -0,0 +1,7 @@
<style>
h1 {
color: red;
}
</style>

<h1>Great success!</h1>
13 changes: 13 additions & 0 deletions test/apps/csp-nonce/src/server.js
@@ -0,0 +1,13 @@
import polka from 'polka';
import * as sapper from '@sapper/server';

import { start } from '../../common.js';

const app = polka()
.use((req, res, next) => {
res.locals = { nonce: "nonce"};
next();
})
.use(sapper.middleware());

start(app);
82 changes: 82 additions & 0 deletions test/apps/csp-nonce/src/service-worker.js
@@ -0,0 +1,82 @@
import * as sapper from '@sapper/service-worker';

const ASSETS = `cache${sapper.timestamp}`;

// `shell` is an array of all the files generated by webpack,
// `files` is an array of everything in the `static` directory
const to_cache = sapper.shell.concat(sapper.files);
const cached = new Set(to_cache);

self.addEventListener('install', event => {
event.waitUntil(
caches
.open(ASSETS)
.then(cache => cache.addAll(to_cache))
.then(() => {
self.skipWaiting();
})
);
});

self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(async keys => {
// delete old caches
for (const key of keys) {
if (key !== ASSETS) await caches.delete(key);
}

self.clients.claim();
})
);
});

self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;

const url = new URL(event.request.url);

// don't try to handle e.g. data: URIs
if (!url.protocol.startsWith('http')) return;

// ignore dev server requests
if (url.hostname === self.location.hostname && url.port !== self.location.port) return;

// always serve assets and webpack-generated files from cache
if (url.host === self.location.host && cached.has(url.pathname)) {
event.respondWith(caches.match(event.request));
return;
}

// for pages, you might want to serve a shell `index.html` file,
// which Sapper has generated for you. It's not right for every
// app, but if it's right for yours then uncomment this section
/*
if (url.origin === self.origin && routes.find(route => route.pattern.test(url.pathname))) {
event.respondWith(caches.match('/index.html'));
return;
}
*/

if (event.request.cache === 'only-if-cached') return;

// for everything else, try the network first, falling back to
// cache if the user is offline. (If the pages never change, you
// might prefer a cache-first approach to a network-first one.)
event.respondWith(
caches
.open(`offline${sapper.timestamp}`)
.then(async cache => {
try {
const response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
} catch(err) {
const response = await cache.match(event.request);
if (response) return response;

throw err;
}
})
);
});
14 changes: 14 additions & 0 deletions test/apps/csp-nonce/src/template.html
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset='utf-8'>

%sapper.base%
%sapper.styles%
%sapper.head%
</head>
<body>
<div id='sapper'>%sapper.html%</div>
%sapper.scripts%
</body>
</html>
35 changes: 35 additions & 0 deletions test/apps/csp-nonce/test.ts
@@ -0,0 +1,35 @@
import * as assert from 'assert';
import { build } from '../../../api';
import { AppRunner } from '../AppRunner';

describe('csp-nonce', function() {
this.timeout(10000);

let r: AppRunner;

// hooks
before('build app', () => build({ cwd: __dirname }));
before('start runner', async () => {
r = await new AppRunner().start(__dirname);
});

after(() => r && r.end());

it('includes a script nonce', async () => {
await r.load('/');

assert.equal(
await r.page.$eval('script:not([src])', node => node.getAttribute('nonce')),
'nonce'
);
});

it('includes a style nonce', async () => {
await r.load('/');

assert.equal(
await r.page.$eval('style', node => node.getAttribute('nonce')),
'nonce'
);
});
});

0 comments on commit 047b1c9

Please sign in to comment.