Skip to content

Commit

Permalink
Support Strict CSP with a nonce (#482)
Browse files Browse the repository at this point in the history
Fixes #482 423
  • Loading branch information
dav-is authored and giuseppeg committed Sep 5, 2018
1 parent 5ddef29 commit 03af155
Show file tree
Hide file tree
Showing 7 changed files with 490 additions and 319 deletions.
16 changes: 16 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,22 @@ It's **paramount** that you use one of these two functions so that
the generated styles can be diffed when the client loads and
duplicate styles are avoided.

### Content Security Policy

Strict [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) is supported.

You should generate a nonce **per request**.
```js
import nanoid from 'nanoid'

const nonce = Buffer.from(nanoid()).toString('base64') //ex: N2M0MDhkN2EtMmRkYi00MTExLWFhM2YtNDhkNTc4NGJhMjA3
```

You must then pass a nonce to either `flushToReact({ nonce })` or `flushToHTML({ nonce })` **and** set a `<meta property="csp-nonce" content={nonce} />` tag.

Your CSP policy must share the same nonce as well (the header nonce needs to match the html nonce and remain unpredictable).
`Content-Security-Policy: default-src 'self'; style-src 'self' 'nonce-N2M0MDhkN2EtMmRkYi00MTExLWFhM2YtNDhkNTc4NGJhMjA3';`

### External CSS and styles outside of the component

In styled-jsx styles can be defined outside of the component's render method or in separate JavaScript modules using the `styled-jsx/css` library. `styled-jsx/css` exports three tags that can be used to tag your styles:
Expand Down
17 changes: 10 additions & 7 deletions src/lib/stylesheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ const isProd = process.env && process.env.NODE_ENV === 'production'
const isString = o => Object.prototype.toString.call(o) === '[object String]'

export default class StyleSheet {
constructor(
{
name = 'stylesheet',
optimizeForSpeed = isProd,
isBrowser = typeof window !== 'undefined'
} = {}
) {
constructor({
name = 'stylesheet',
optimizeForSpeed = isProd,
isBrowser = typeof window !== 'undefined'
} = {}) {
invariant(isString(name), '`name` must be a string')
this._name = name
this._deletedRulePlaceholder = `#${name}-deleted-rule____{}`
Expand All @@ -29,6 +27,10 @@ export default class StyleSheet {
this._tags = []
this._injected = false
this._rulesCount = 0

const node =
this._isBrowser && document.querySelector('meta[property="csp-nonce"]')
this._nonce = node ? node.getAttribute('content') : null
}

setOptimizeForSpeed(bool) {
Expand Down Expand Up @@ -225,6 +227,7 @@ export default class StyleSheet {
)
}
const tag = document.createElement('style')
if (this._nonce) tag.setAttribute('nonce', this._nonce)
tag.type = 'text/css'
tag.setAttribute(`data-${name}`, '')
if (cssString) {
Expand Down
9 changes: 6 additions & 3 deletions src/server.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
import React from 'react'
import { flush } from './style'

export default function flushToReact() {
export default function flushToReact(options = {}) {
return flush().map(args => {
const id = args[0]
const css = args[1]
return React.createElement('style', {
id: `__${id}`,
// Avoid warnings upon render with a key
key: `__${id}`,
nonce: options.nonce ? options.nonce : undefined,
dangerouslySetInnerHTML: {
__html: css
}
})
})
}

export function flushToHTML() {
export function flushToHTML(options = {}) {
return flush().reduce((html, args) => {
const id = args[0]
const css = args[1]
html += `<style id="__${id}">${css}</style>`
html += `<style id="__${id}"${
options.nonce ? ` nonce="${options.nonce}"` : ''
}>${css}</style>`
return html
}, '')
}
30 changes: 30 additions & 0 deletions test/helpers/with-mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export default function withMock(mockFn, testFn) {
return t => {
const cleanUp = mockFn(t)
if (typeof cleanUp !== 'function') {
throw new TypeError('mockFn should return a cleanup function')
}
testFn(t)
cleanUp(t)
}
}

export function withMockDocument(t) {
const originalDocument = global.document
// We need to stub a document in order to simulate the meta tag
global.document = {
querySelector(query) {
t.is(query, 'meta[property="csp-nonce"]')
return {
getAttribute(attr) {
t.is(attr, 'content')
return 'test-nonce'
}
}
}
}

return () => {
global.document = originalDocument
}
}
47 changes: 47 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,50 @@ test('server rendering', t => {
t.is(0, flush().length)
t.is('', flushToHTML())
})

test('server rendering with nonce', t => {
function App() {
const color = 'green'
return React.createElement(
'div',
null,
React.createElement(JSXStyle, {
css: 'p { color: red }',
styleId: 1
}),
React.createElement(JSXStyle, {
css: 'div { color: blue }',
styleId: 2
}),
React.createElement(JSXStyle, {
css: `div { color: ${color} }`,
styleId: 3
})
)
}
// Expected CSS
const expected =
'<style id="__jsx-1" nonce="test-nonce">p { color: red }</style>' +
'<style id="__jsx-2" nonce="test-nonce">div { color: blue }</style>' +
'<style id="__jsx-3" nonce="test-nonce">div { color: green }</style>'

// Render using react
ReactDOM.renderToString(React.createElement(App))
const html = ReactDOM.renderToStaticMarkup(
React.createElement('head', null, flush({ nonce: 'test-nonce' }))
)

t.is(html, `<head>${expected}</head>`)

// Assert that memory is empty
t.is(0, flush({ nonce: 'test-nonce' }).length)
t.is('', flushToHTML({ nonce: 'test-nonce' }))

// Render to html again
ReactDOM.renderToString(React.createElement(App))
t.is(expected, flushToHTML({ nonce: 'test-nonce' }))

// Assert that memory is empty
t.is(0, flush({ nonce: 'test-nonce' }).length)
t.is('', flushToHTML({ nonce: 'test-nonce' }))
})
Loading

0 comments on commit 03af155

Please sign in to comment.