Avoiding XSS in React is Still Hard


Introduction

I’ve spent the last few weeks thinking about React from a secure coding perspective. Since React is a library for creating component based user interfaces, most of the attack surface is related to issues with rendering elements in the DOM. The smart folks over at Facebook have handled this by building automatic escaping into the React DOM library code.

Built-in Escaping is Limited

The escaping code in React DOM works great when you are passing a string value into [...children] . Notice the other two arguments to React.createElement type and [props], values passed into them are unescaped.

// From https://reactjs.org/docs/react-api.html#createelement
React.createElement(
type,
[props],
[...children]
)

Data Passed as Props is Unescaped

When you pass data into a React element via props, the data is not escaped before being rendered into the DOM. This means that an attacker can control the raw values inside of HTML attributes. A classic XSS attack is to put a URL with a javascript: protocol into the href value of an anchor tag. When a user clicks on the anchor tag the browser will execute the JavaScript found in the href attribute value.

// Classic XSS via anchor tag href attribute.
<a href="javascript: alert(1)">Click me!</a>

This classic XSS attack still works in React when rendering a component with React DOM.

// Classic XSS via anchor tag href attribute in a React component.
ReactDOM.render(
<a href="javascript: alert(1)">Click me!</a>,
document.getElementById('root')
)

Mitigating XSS Attacks on React Props

There are a few options for mitigating attacks on React components. You could do contextual escaping for the prop value.

You would need a list of known bad values for each attribute and you would need to know which characters to escape to make the value benign. Historically this hasn’t gone very well.

You could also try filtering, which also hasn’t gone very well in the past.

For prop values you probably want to use validation. Here is a common attempt at avoiding XSS with blacklist style validation.

const URL = require('url-parse')
const url = new URL(attackerControlled)
function isSafe(url) {
if (url.protocol === 'javascript:') return false
  return true
}
isSafe(URL('javascript: alert(1)')) // Returns false
isSafe(URL('http://www.reactjs.org')) // Returns true

This approach seems to be working, but as we will see shortly it will only prevent simple attacks that don’t attempt to evade the blacklist.

Validating Against a Blacklist is Hard

In the example above we are doing a lot of things right. We are using the npm module called url-parse to parse the URL instead of hand-rolling a solution. We are attempting to validate the url with an isolated reusable function, so that our security audits and remediation tasks will be easier. We are handling the failure case first in the function and using an early return strategy to handle a failure.

It is usually a bad idea to use blacklists to enforce validation. Here we can defeat the isSafe function using our spacebar.

const URL = require('url-parse')
function isSafe(url) {
if (url.protocol === 'javascript:') return false
  return true
}
isSafe(URL(' javascript: alert(1)')) // Returns true
isSafe(URL('http://www.reactjs.org')) // Returns true

Reading npm Module Documentation is Hard (Not Joking)

The reason that isSafe(URL(' javascript: alert(1)')) doesn’t work as intended in our isSafe function is described in the documentation page for url-parse over on npm.

baseURL (Object | String): An object or string representing the base URL to use in case urlis a relative URL. This argument is optional and defaults to location in the browser.

So when we pass the string javascript: alert(1) with a leading space I think url-parse assumes we are providing a relative URL and it is happy to assume the protocol from the browser’s location. In this case it believes the protocol for javascript: alert(1) is http:.

const URL = require('url-parse')
URL(' javascript: alert(1)').protocol // Returns http:

If we look further down in the documentation for url-parse on npm we will find this part.

Note that when url-parse is used in a browser environment, it will default to using the browser's current window location as the base URL when parsing all inputs. To parse an input independently of the browser's current URL (e.g. for functionality parity with the library in a Node environment), pass an empty location object as the second parameter:

It tells us that if we pass an empty location object as the second parameter to instances of url-parse we can disable the behavior that is causing all strings to be treated as having the browser’s location protocol as their protocol.

const URL = require('url-parse')
URL(' javascript: alert(1)', {}).protocol // Returns ""

With an empty object as the second argument we can see that we get an empty string back as the protocol for javascript: alert(1) .

Fixing that Blacklist Function

Looking back at the isSafe(url) blacklist function we can improve it by looking for empty strings in addition to the javascript: protocol.

const URL = require('url-parse')
const url = new URL(attackerControlled)
function isSafe(url) {
if (url.protocol === 'javascript:') return false
if (url.protocol === '') return false
  return true
}
isSafe(URL('javascript: alert(1)', {})) // Returns false
isSafe(URL('http://www.reactjs.org')) // Returns true

Oh yeah, this is a post about React XSS security. Let’s get back to that now. We can try to use our improved isSafe function to do some validation in a React component.

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import URL from 'url-parse'
class SafeURL extends Component {
isSafe(dangerousURL, text) {
const url = URL(dangerousURL, {})
if (url.protocol === 'javascript:') return false
if (url.protocol === '') return false
    return true
}
  render() {
const dangerousURL = this.props.dangerousURL
const safeURL = this.isSafe(dangerousURL) ? dangerousURL : null
    return <a href={safeURL}>{this.props.text}</a>
}
}
ReactDOM.render(
<SafeURL dangerousURL=" javascript: alert(1)" text="Click me!" />,
document.getElementById('root')
)

This example above is not injectable, maybe.

Whitelist Validation

I’ve never feel very comfortable with blacklist based solutions for security. It would be like if you heard a noise in your house at night and went downstairs to find an unfamiliar person standing in your living room and in order to figure out if they belonged in your house you looked them up in a criminal offenders database.

I prefer whitelist based solutions. I know who is supposed to be in my house.

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
const URL = require('url-parse')
class SafeURL extends Component {
isSafe(dangerousURL, text) {
const url = URL(dangerousURL, {})
if (url.protocol === 'http:') return true
if (url.protocol === 'https:') return true
    return false
}
  render() {
const dangerousURL = this.props.dangerousURL
const safeURL = this.isSafe(dangerousURL) ? dangerousURL : null
    return <a href={safeURL}>{this.props.text}</a>
}
}
ReactDOM.render(
<SafeURL dangerousURL=" javascript: alert(1)" text="Click me!" />,
document.getElementById('root')
)