XXS Concerns In React
React can help protect us from XSS issues but it doesn't make it impossible. In this post we will discuss how React keeps us safe and when we still need to be careful.
In a previous post we outlined XSS as the following:
XSS is when untrusted input reaches an execution sink.
We then dove into what this looks like within JavaScript, why it is an issue and how we may avoid it.
Now in this post we’re going to discuss XSS within React… Why?
Well React does a few things for us:
- It removes many instances of string to DOM sinks from our every day work. We don’t need to do things like
el.innerHTML = ...for example. - React also escapes text by default.
So do we still need to worry about XSS within React? Sadly, the answer is yes.
React makes safe things easy but it doesn’t make unsafe code impossible.
We’re going to dive into a few concerns we need to watch out for when using React.
First lets dive into what React actually does for us.
React’s default
By default React will always render values as text content, not as HTML.
For example:
export function ProfileBio({ bio }) {
return <p>{bio}</p>;
}
In the above example bio is treated as pure text. So even if bio contained code, such as:
<img src="x" onerror="alert(1)" />
It would just be displayed as literal text, it will not be parsed as a DOM node. In these examples React is escaping the characters for us so it doesn’t interpret them as markup.
React will also escape attribute values for us, for example:
export function Greeting({ name }) {
return <div title={name}>Hello {name}</div>;
}
The name value is escaped and like bio above will be treated as a literal string value but not all are 100% safe. React will not validate that a URL is safe when passing to href for example but we will dicuss more in a later section.
When to be concerned in React
HTML Blocks
React is mostly safe but we as developers can still introduce a sink ourselves. One of the most common examples is using the appropriately named dangerouslySetInnerHTML attribute.
When building web applications there are times when we may need to inject a block of HTML into our code that we want the browser to interpret as HTML code, not a string. In those instances we need to use dangerouslySetInnerHTML like so:
export function HtmlBlock({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Now despite the name, dangerouslySetInnerHTML, it isn’t inherently dangerous but coupled with untrusted input we run the risk of introducing a potential XSS attack.
So how can we solve this issue? Well we use a library like DOMPurify to first sanitize the HTML before we attempt to use it:
import DOMPurify from "dompurify";
export function SafeHtml({ html }) {
const clean = DOMPurify.sanitize(String(html), {
USE_PROFILES: { html: true },
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Now we can be confident that the HTML we’re rendering is safe.
If possible we should always try to avoid using dangerouslySetInnerHTML but if it is required then we need to keep the component small, sanitize the input HTML and review/test it well.
URL sinks in React
As discussed before, React will not validate a URL for us. It still escapes the string passed in, so for example:
export function ExternalLink({ url, label }) {
return <a href={url}>{label}</a>;
}
Both url and label will be escaped but React will not check to see if url is a valid URL. This opens the door for an XSS attack, for example url can become javascript:alert(1) or any other unexpected schema.
So how do we avoid this? Well once again if the url is coming from an untrusted input then we need to validate it:
export function safeURL(
input: unknown,
{
allowProtocols = ["http:", "https:"],
allowOrigins,
base = window.location.origin,
}: {
allowProtocols?: string[];
allowOrigins?: string[];
base?: string;
} = {}
): string | null {
try {
const raw = String(input).trim();
const url = new URL(raw, base);
if (!allowProtocols.includes(url.protocol)) return null;
if (allowOrigins && !allowOrigins.includes(url.origin)) return null;
return url.toString();
} catch {
return null;
}
}
Then in our React code we can do:
import { safeURL } from "./safeURL";
export function ExternalLink({ url, label }) {
const href = safeURL(url, {
allowProtocols: ["https:", "http:"],
});
if (!href) return <span>{label}</span>;
return (
<a href={href} target="_blank" rel="noreferrer">
{label}
</a>
);
}
Now we have more confidence as React is escaping the url and then we’re validating it to ensure it isn’t attempting some malicious action.
Other concerns
String to JavaScript execution
We also still have to be wary of any action where we’re trying to process strings in JavaScript.
For example:
eval(untrusted);
new Function(untrusted)();
setTimeout(untrusted, 0); // string form
Are all possible in React but are unsafe. There is no way robust way to sanitize untrusted input into safe JavaScript code so we should actively avoid doing any of the above.
If we need to parse a string from an untrusted source and then run JavaScript code based on that input we should adopt the action pattern:
const ACTIONS = {
openSettings: () => openSettings(),
logout: () => logout(),
};
export function runAction(actionName) {
const fn = ACTIONS[actionName];
if (!fn) return;
fn();
}
Then in our React code:
export function ActionButton({ action }) {
return <button onClick={() => runAction(action)}>Run</button>;
}
Third-party libraries
When using third party libraries we need to be careful about passing input from untrusted sources to them if we don’t know how they render what we’re passing in.
Before we blindly pass that data into these third party components we need to understand how they render that data to ensure we’re not opening ourselves to an XSS attack.
Rendering markdown or rich text
Sometimes we need to work with HTML, for example a workflow may look like this:
- A user or a CMS provides Markdown.
- We convert that Markdown to HTML.
- We then inject that HTML into a page.
We need to keep in mind this rule:
If HTML is involved and we didn’t personally hardcode it, then we treat it as untrusted.
Which means in the above workflow we should either disable raw HTML in the Markdown or sanitize the rendered HTML before we place it in the DOM.
Remote SVGs
Many assume SVGs are just an image format but it is actually possible to include scripts and event handlers within SVGs depending on the context.
So how do we be safe with these?
Well if we need SVGs in our application then:
- Prefer bundling known, and safe, SVGs within the app where we can see the code.
- Use an allowedlist, safe, icon set.
- Don’t just inline random SVG text into the DOM.
Conclusion
While React helps with XSS because it defaults to render as text avoiding many of the interpretation concerns it doesn’t negate the rule:
XSS is when untrusted input reaches an execution sink.
In React, the sinks are still there but they’re just easier to avoid, until we opt back into them.