Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Strict CSP with a nonce #482

Merged
merged 9 commits into from
Sep 5, 2018
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 `flush({ nonce })` or `flushToHTML({ nonce })` **and** set a `<meta property="csp-nonce" content={nonce} />` tag.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should replace flush with flushToReact


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
26 changes: 15 additions & 11 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,9 +27,14 @@ export default class StyleSheet {
this._tags = []
this._injected = false
this._rulesCount = 0

const node =
typeof document === 'object' &&
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you can use this._isBrowser

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like changing this might break my test 😟 because there isn't any browser testing implemented in this repo ☹️

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably in your test you can do global.window = true.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@giuseppeg Will this not cause unintended side effects because we are telling the application it is running in a browser and it's not really? If somewhere else in the application it checks isBrowser and if I set it to true, it might try to execute some other type of browser only script.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an isolated unit test so it is perfectly fine. If you are talking about src/lib/stylesheet.js instead that's fine too since you are checking that typeof document === 'object' is defined i.e. you are in a browser environment.

Copy link
Contributor Author

@dav-is dav-is Aug 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was right about the unintended side effects. It's possible that isBrowser == true and typeof document === 'undefined', which will create an error.

Making the change you requested, allows other tests to break code when it sets isBrowser to true, even when it's not technically in a browser. I feel like this is an issue of it's own.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should behave the same when isBrowser is true. If there are failing tests this is a good thing and we should look into this issue. In a real browser typeof document === 'object' will be the same as using this._isBrowser because the latter is just typeof window !== 'undefined;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want me to break the tests, I will, I just don't have time to fix them myself.

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

setOptimizeForSpeed(bool) {
setOptimizeForSpeed(bool, options = { flush: {} }) {
invariant(
typeof bool === 'boolean',
'`setOptimizeForSpeed` accepts a boolean'
Expand All @@ -41,7 +44,7 @@ export default class StyleSheet {
this._rulesCount === 0,
'optimizeForSpeed cannot be when rules have already been inserted'
)
this.flush()
this.flush(options.flush)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need to pass options.flush?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case that method gets other options eventually.

this._optimizeForSpeed = bool
this.inject()
}
Expand All @@ -50,7 +53,7 @@ export default class StyleSheet {
return this._optimizeForSpeed
}

inject() {
inject(options = { flush: {} }) {
invariant(!this._injected, 'sheet already injected')
this._injected = true
if (this._isBrowser && this._optimizeForSpeed) {
Expand All @@ -62,7 +65,7 @@ export default class StyleSheet {
'StyleSheet: optimizeForSpeed mode not supported falling back to standard mode.'
) // eslint-disable-line no-console
}
this.flush()
this.flush(options.flush)
this._injected = true
}
return
Expand Down Expand Up @@ -225,6 +228,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
}, '')
}
4 changes: 2 additions & 2 deletions src/stylesheet-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export default class StyleSheetRegistry {
this.remove(props)
}

flush() {
this._sheet.flush()
flush(options = {}) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case someone is using this method and needs to set the nonce.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is necessary also StyleSheet#flush is not using those options https://github.com/zeit/styled-jsx/blob/368a6e4e3e1211e84b6bc4bb4db44dcd426a0bd9/src/lib/stylesheet.js#L192

Can you revert this change?

this._sheet.flush(options)
this._sheet.inject()
this._fromServer = undefined
this._indices = {}
Expand Down
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' }))
})
23 changes: 23 additions & 0 deletions test/stylesheet-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,29 @@ test('add - sanitizes dynamic CSS on the server', t => {
])
})

test('add - nonce is properly fetched from meta tag', t => {
const originalDocument = global.document
// We need to stub a document in order to simulate the meta tag
global.document = {
dav-is marked this conversation as resolved.
Show resolved Hide resolved
querySelector(query) {
t.is(query, 'meta[property="csp-nonce"]')
return {
getAttribute(attr) {
t.is(attr, 'content')
return 'test-nonce'
}
}
}
}

const registry = makeRegistry()
registry.add({ styleId: '123', css: [cssRule] })

t.is(registry._sheet._nonce, 'test-nonce')

global.document = originalDocument
})

// registry.remove

test('remove', t => {
Expand Down