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.
Your Information will be kept private.
Your Information will be kept private.
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.
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.
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 code | Explanation |
---|---|
<div | The <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. |
__html | This 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 parameters | Parameter values (examples) | Exploitable? |
---|---|---|
type | No | |
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 |
children | No | |
children props | children: null props: {dangerouslySetInnerHTML: {__html: | In most cases |
props | props: {dangerouslySetInnerHTML: {__html: | 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.
Frequently asked questions
Is it possible to get cross-site scripting (XSS) vulnerabilities in React web applications?
The React Javascript framework was generally designed to prevent cross-site scripting (XSS) by default. However, in some cases, XSS is still possible in React web applications, especially when developers deliberately use some unsafe constructs that React provides.
Learn more about cross-site scripting (XSS).
How can developers mitigate cross-site scripting (XSS) risks when developing React-based web applications?
The best way to minimize the risk of XSS in React applications is to understand the framework and use it as intended by its designers. When used correctly, built-in React constructs can prevent most of the typical XSS vulnerabilities.
Read about best practices for building web applications that are secure by design.
What are the potential impacts of cross-site scripting (XSS) attacks on the security and functionality of React web applications?
Cross-site scripting (XSS) attacks in general can pose significant risks to the security and functionality of web applications, especially when they are unexpected because a framework like React is supposed to prevent them. Although traditionally considered low-impact and limited to the client side, XSS vulnerabilities can be far more impactful with server-side JavaScript and API-based architectures.
Read about the particular importance of XSS prevention in an API-first world.