Cross-site Scripting in React Web Applications

React is a popular JavaScript framework for building user interfaces. This article shows why it was developed, how it handles user-controlled inputs, and what you should do to prevent cross-site scripting when working with React’s type, props, and children attributes.

Cross-site Scripting in React Web Applications

In this article, we will examine how React prevents cross-site scripting by default and in which cases cross-site scripting (XSS) is still possible. We will first take a look at the developments that made React possible, starting from the infamous browser wars that led to blazing-fast JavaScript rendering. We will also examine the JSX syntax extension, React elements, and how user-controllable parameters are handled.

Cross-site scripting in React

The Browser Wars and Speed Improvements

For many years, browser developers tried to one-up each other by adding exclusive new features, improving overall performance and imitating their competitors. This period is often referred to as the browser wars. While that sounds like good news for consumers in general, it also led to some problems.

User-Agent Madness

For example, as a byproduct of the browser wars, User-Agent strings – the identification data that your browser sends to the web server you visit – are virtually impossible to read. Let’s take a look at the User-Agent string the Chrome browser uses on Microsoft Windows:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36

Is it Mozilla, Safari or Chrome? It says AppleWebKit (with an ancient version number), even though Chrome replaced it with Blink in 2013. You may want to look at the history of User-Agent strings to get to the bottom of it. It’s hilarious and awful at the same time, and highly recommended. 

Much of the confusion stems from the fact that browser developers added new features so rapidly that web application developers couldn’t keep up. Mozilla, for example, had support for frames, but other browsers didn’t. So developers would check whether the word Mozilla appeared in the User-Agents and if it did, they would send a version of their website with frames. If it didn’t, they sent a different one.

By the time Internet Explorer added frames, many developers used that check and therefore IE would never get a version of the page with frames enabled. Apparently, that’s the reason why IE eventually started including the Mozilla string in its User-Agent instead of waiting for web developers to include IE in their browser checks. Then more of these strings followed, which resulted in the User-Agent mess we have today. 

That is a perfect example of how the browser wars led to messy solutions for the sake of keeping up with the competition, It also also shows how hard it is to get rid of temporary fixes once they are implemented and widely adopted. And the browser wars weren’t only fought in the virtual realm. Let me quote this passage on the Browser wars from Wikipedia:

In October 1997, Internet Explorer 4.0 was released. The release party in San Francisco featured a ten-foot-tall letter “e” logo. Netscape employees showing up to work the following morning found the logo on their front lawn, with a sign attached that read “From the IE team… We Love You.” The Netscape employees promptly knocked it over and set a giant figure of their Mozilla dinosaur mascot atop it, holding a sign reading “Netscape 72, Microsoft 18” representing the market distribution.

The craziest bit of this whole story is that there was an Internet Explorer 4.0 release party… Those were trying times for Internet users.

JavaScript Performance Improvements

But apart from weird User-Agent strings and dinosaurs fighting the letter “e”, the browser wars also had their perks. The main beneficiaries were users, who couldn’t care less about User-Agents or prefixes in CSS properties. One metric turned out to be one that users cared about the most – speed. You can see that to this day, each browser you encounter claims to be X times faster than its counterpart. Even if those claims are true, you probably wouldn’t notice any difference.

That’s because browsers and their respective JavaScript engines are blazing fast due to numerous optimizations and the improved state of hardware they run on. In fact, JavaScript engines became so fast that people got the idea to use them to write server-side applications as well. Node.js, for example, uses Chrome’s v8 engine. Of course, client-side applications also profited from these performance improvements.

This in turn led to whole web applications that were rendered on the client side, and various frameworks that would make this task as easy as possible. One of them is React, a library maintained by Facebook and a large developer community. After Facebook open-sourced React, it quickly rose in popularity and is widely used today.

Why Would I Use React?

The reasons for using React are many, but I will only talk about the security benefits in this article. React uses secure defaults when it comes to dynamic content, which I’ve discussed in detail on Application Security Weekly #60. Usually, React is used in conjunction with JSX, an XML-like syntax extension for JavaScript, which is transpiled to actual JavaScript code by a tool like Babel.js. While React can be used without JSX, the extension allows developers to write HTML code in JavaScript.

Additionally, React encourages you to use components – parts of code that you can reuse throughout your application. Let’s see what a typical React component looks like. If you find some of the syntax odd, it’s because of JSX and the fact that Babel.js allows you to use certain features that aren’t (yet) available for JavaScript in browsers.

import React, { Component } from 'react'
class CurrentLocation extends Component {
    render() {
        return <div>You are here: {decodeURIComponent(document.location)}</div>
    }
}
export default CurrentLocation

If you develop applications, you should be alarmed due to the obvious lack of sanitization. Right in the return statement, we are seemingly mixing HTML with unsanitized, decoded user input. In most cases, this is a sure recipe for a cross-site scripting vulnerability. But not so quick!

JSX is fooling you a little bit for the sake of user-friendliness. It looks like what we are doing here is somehow concatenating a string with the decoded document.location value and putting it directly into a <div> tag that is directly included in the DOM as is. But that’s not exactly how this works.

The code snippet above is transpiled to the equivalent of the following code: 

import React, { Component } from 'react'
class CurrentLocation extends Component {
    render() {
        return React.createElement(
            'div',
            null,
            'You are here: ',
            decodeURIComponent(document.location)
        )
    }
}
export default CurrentLocation

As you can see, the render method of the CurrentLocation class returns the result of the React.createElement call. Our JSX component was taken apart and its content was turned into parameters for the createElement function. Let’s take a look at the documentation to find out the name and purpose of each of these parameters.

React documentation for createElement

So it seems like the first argument describes the type, the second argument is the properties, and the third argument is the children of the element. In this case, the element’s sole child is a string. We didn’t define any properties and the type is a <div> tag. 

React will internally append the string in a way that prevents it from being parsed as HTML by web browsers. This is a very good default way to do it, as it allows you to use dynamic user input from different sources and always handle it in a secure way, unless you specify that you want the data to be treated as HTML. This prevents cross-site scripting vulnerabilities effectively.

Since we are talking about “secure defaults”, you may wonder if there is a way to deviate from the default. Can we render a string as HTML in React? The answer is yes, but luckily it’s more intuitive to do it the secure way. In fact, it’s ridiculously complicated and very hard to get it wrong by accident. A JSX React element that prints unsanitized HTML code looks like this:

<div dangerouslySetInnerHTML = {{__html: `You are here: ${decodeURIComponent(document.location)}`}} />

As you see, if we don’t use any child elements, we don’t need a closing </div> tag. Instead, we can use a self-closing tag that ends with the /> combination. The rest of the syntax looks quite confusing, so let’s break it apart before talking about its meaning.

JSX codeExplanation
<divThe <div> tag we’ve seen before.
dangerouslySetInnerHTML=This is a property of the React element we are creating.
{{}}The double curly brackets just mean that the value of the property is an object. It looks confusing, since JSX uses these brackets to allow JavaScript code to be added, while JavaScript uses them to denote objects.
__htmlThis is a property of the dangerouslySetInnerHTML object.

In order to allow actual HTML code in a React element, you therefore need to use the dangerouslySetInnerHTML property with an object containing the __html key. Doing all of this accidentally is virtually impossible.

We also came across the prop parameter now in React.createElement. It’s the second one and in our previous example it was set to null. Now it would be set to the following value:

{
   dangerouslySetInnerHTML: {
      __html: `You are here: ${decodeURIComponent(document.location)}`
   }
}

Additionally, the children property would now be null. But just because it’s hard to do this by accident, this doesn’t mean that it’s impossible for an attacker to abuse. Let’s take a look at the following example code:

render() {
    let userInput = JSON.parse(`{
        "tag": "div",
        "props": {
            "dangerouslySetInnerHTML": {
                "__html": "<img src = 'x' onerror = 'alert(1)'>"
            }
        },
        "children": null
    }`)
    return <userInput.tag {...userInput.props}>{userInput.children}</userInput.tag>
}

Here we assume that a user provides JSON data which will then be used in a JSX React element. It is possible for user input to end up in all three of the React.createElement parameters. The code above would lead to an XSS vulnerability. However, it’s highly unlikely that an attacker would be able to provide all three of the parameters. 

Exploiting createElement’s Parameters

So, which conditions need to be met in order to execute script code? The dangerouslySetInnerHTML method is not the only way to execute JavaScript. Let’s take a look at the possibilities.

The Type Attribute

This is often a string with the name of the HTML tag you want to create. It can also be a React component or fragment. We are not going into detail on either of them because the former is either a function or a class and the latter is a Symbol (more on those later). There is almost no way to pass either of them as an attacker, so we’ll look at the string inputs instead.

On its own, this parameter is not dangerous. There is no way to execute dangerous code just by injecting a single HTML tag without any parameters or inner content. This would only be possible if there was some kind of bug in the way React creates elements (of which I am not aware).

However, if both the type attribute and the props attribute are controllable, an attacker can execute script code – we’ll get to that in a minute.

The Props Attribute

This attribute is a little easier to exploit in multiple cases. Most of the time, if you control it, you are able to execute JavaScript code. There is a vast number of possibilities how you could do it. However, there is a catch. You won’t be able to use event handlers, such as onclick or onload. The reason is that React likes to handle event handlers itself, therefore you can’t really pass an event handler such as onload down to the rendered HTML tag. Instead, you’d need to use the onLoad event handler (written in camel case). This event handler expects a function instead of a string. As we’ve already established, in almost all use cases, we can’t pass functions or classes. Therefore, event handlers can’t be used.

However, while React will prevent XSS in children of its elements, this does not apply to properties. First of all, we could use the dangerouslySetInnerHTML property, as we’ve seen above. But you could also use the href attribute in an anchor tag, like so:

React.createElement('a', {href: "javascript:alert(1)"}, 'click')

React won’t save you from javascript: URIs, which is certainly something you must keep in mind.

The Children Attribute

According to React, as they specifically state in their documentation, it is safe to embed user input into JSX. And it’s true – if you use user input in a React child, nothing bad will happen on its own. React will automatically escape the value for you, without any additional security measures required. 

And even though strings aren’t the only type accepted by the children function, they are the only type that a malicious user could pass, even if the input comes from a function such as JSON.parse that would generally allow you to create objects as a user.

Valid input would be an object created by React.createElement. This is problematic, because if an attacker crafts an object with the same structure as the one returned by React.createElement, they could inject an element where they control the type, props and children parameters. React solved that problem by using Symbols.

A Symbol can be used in place of a unique value. It doesn’t actually have a value like, say, a random number or a string. Each time you call the Symbol function, a new symbol is created, like this:

const unique = Symbol()

React will create such a Symbol once it’s running. Then it will add a $$type attribute whenever it creates a new element. The value of $$type is set to this created symbol. Before it renders an element, it checks if the element’s $$type attribute contains the Symbol and if it doesn’t, React refuses to render it (at least in my tests). 

So even if an attacker manages to create an object that is almost exactly the same as the one created by React.createElement, the $$type attribute will never have the correct value and therefore it won’t render.

Now that you see how serious React is about security, you may get the idea to allow a user to control both the child and the tag name. In general, that shouldn’t be problematic, but what about script tags? Wouldn’t an attacker be able to use something like the code below?

React.createElement('script', null, 'alert(1)');

Actually, the answer is no. This wouldn’t be possible due to a clever trick React uses when creating <script> tags. React will create the tag for you and embed it on the page, but won’t allow it to execute due to the use of the parser-inserted flag. 

Let’s see how you could insert a script tag into a page using JavaScript.

Method One

One way to do it is by doing what React does for most of the tags it creates: using document.createElement. This is the first method:

const script = document.createElement('script');
script.innerText = 'alert(1)';
document.body.appendChild(script);

This will create a script element, add the JavaScript content that should be executed, and append it to the DOM. Once the element is appended, it will execute the code it contains.

Method Two 

React can’t use the first method when it doesn’t want scripts created via JSX to execute. Instead, it uses a construct like this second method below:

const div = document.createElement('div')
div.innerHTML = '<script><'+'/script>'
const script = div.firstChild;

This will also yield a script element, but now it’s not created using createElement. Instead, the element is parsed by the HTML parser and then added to the DOM. The HTML5 specification mentions the following:

Script elements inserted using innerHTML do not execute when they are inserted.

So that seems like an appropriate way to prevent scripts from loading. React will internally check whether the tag you want to create is of the type “script”. If that’s the case, it will use something that resembles Method Two to create the element. If it’s not of the type “script”, it will use Method One, which I believe is faster.

A Bug Problem

However, there is a problem. The code responsible for checking whether the type property contains a script has a bug. Let’s take a look at the check:

if (type === 'script') {
   // Create the script via .innerHTML so its "parser-inserted" flag is
   // set to true and it does not execute
   const div = ownerDocument.createElement('div');
   div.innerHTML = '<script><' + '/script>'; // eslint-disable-line
   // This is guaranteed to yield a script element.
   const firstChild = ((div.firstChild: any): HTMLScriptElement);
   domElement = div.removeChild(firstChild);
} else if (typeof props.is === ‘string’) {

As you can see, this is the code that is responsible for creating script elements. It checks whether the type variable contains the word “script” and if it does, Method Two is used. However, while react urges you to write tag names in lowercase and component names in uppercase, HTML isn’t very strict when it comes to casing. 

The strings “SCRIPT”, “scRipt”, and “script” are all equally valid and result in the creation of a <script> tag. And here is the problem. The type variable is only checking whether the tag contains “script” in lowercase, but not whether it contains “scRipt” or “scriPt”. We opened an issue and proposed a fix on Github before submitting a pull request that would have fixed the type variable check. 

It turned out that this issue was already known and reported back in 2018, but due to a misunderstanding, it wasn’t fixed. However, quite some time after we proposed a fix, React developers decided not to implement it. While it would add some security benefits, it seems like the additional check would generate too much of an overhead to be included. This is something you need to keep in mind when allowing user-controlled input in your React components.

When Is User-Controlled Input Dangerous in React?

You can check the list below to see in which circumstances user input can be dangerous in a React.createElement call. The list is by no means exhaustive and is intended to show you that even if React takes care of sanitizing dangerous input in the vast majority of cases, you should still be really careful where and when to allow user input in your application.

Controllable parametersParameter values (examples)Exploitable?
typeNo
type
children
type: 'scRipt'
children: 'alert(1)'
Yes
type
props
type: 'iframe'
props: {src: 'javascript:alert(1)'}
Yes
type
props
children
type: 'a',
props: {href: 'javascript:alert(1)'}
children: 'click me'
Yes
childrenNo
children
props
children: null
props: {dangerouslySetInnerHTML: {__html:
    '<img src=x onerror=alert(1)>'}}
In most cases
propsprops: {dangerouslySetInnerHTML: {__html:
     '<img src=x onerror=alert(1)>'}}
In most cases

How Do You Prevent Cross Site Scripting in React?

As a rule of thumb, avoid properties that are completely user-controllable. If you do allow user input as a value of certain properties, make sure that attackers can’t insert any script code. This includes properties such as src, href, srcdoc, and possibly others. Where React really shines is when the only user-controllable input is the child parameter – then it automatically applies sanitization without any action or thought required by the programmer.

Sven Morgenroth

About the Author

Sven Morgenroth - Senior Security Engineer