Introduction
Security is as important as ever. Despite this, frontend security often takes the back seat compared to shipping fast, and being performant.
This is in part because modern browsers and frontend frameworks providing a decent amount of security by default.
Technologies we often take for granted like https and auto-escaping template engines eliminate large classes of attacks for us.
While this is great, it can lead to a sense of complacency when it comes to building secure frontends if we aren’t vigilant.
Security in general, is deeply nuanced and broad. Which can feel intimidating to approach sometimes.
There’s tons of articles and cheat sheets available online, that can help build a working knowledge of common exploits.
In this article we’ll take a slightly different approach. We’ll look at how we can start to develop more secure frontend code by default, without necessarily having an encyclopedic knowledge of exploits.
We’ll then take a look at developing a kind of frontend security spidey sense for the most common frontend security issue, cross site scripting (XSS).
Cross site scripting is consistently in the top 10 OWASP common attacks on web applications. As a frontender it’s worth getting well acquainted with this dark art, given the likely hood of encountering it in the wild some day.
We’ll use React for the specific examples, but the principles outlined can also apply to any frontend framework.
Thinking like a hacker
Computer scientists focus on worst case scenarios when designing algorithms and data structures. This mindset helps keep the code efficient and scale to large inputs.
As we gain more experience, we often start to develop a kind of vigilance developed from past mistakes and experiencing approaches that didn’t go well.
This creates a kind of background process in our minds when writing or reviewing code.
How’s this approach going to evolve over time? What’s going to go wrong here when this hits production?
The good news is we don’t always have to learn the hard way. We don’t need to have our frontends continually hacked before we develop a security focussed background process.
We just need to ask the right questions to put us on the right track. The same sort of questions a seasoned hacker may ponder when attempting to break into a system.
In other words to put ourselves in the shoes of a malicious hacker. Compared to computer scientists who are systematic in their analysis, a hacker takes a no holds barred approach to finding any potential vulnerability and exploiting it to gain a foothold.
Let’s see what this means in the form of questions we can start to ask when writing or reviewing code:
How would someone intentionally break this ?
There’s a phenomenon in software engineering where small bugs are coupled to much larger ones.
In software testing literature, this is known as the coupling effect. Where testing for simple bugs also implicitly tests for much more complicated bugs.
We may have experienced something like this where a super bad bug ended up being a simple one line fix somewhere unexpected.
This is to say that the small things matter. Often times a hacker will exploit a small bug somewhere to progressively get more footholds, to keep probing and digger further into the system.
What this means practically is once we have the happy path implemented for a function or component, it’s worth thinking about how it would handle weird input or usages that could lead to unexpected behaviour.
Even if the piece of code is something internal, and never deals with user generated data, this type of thinking can help build more resilient code if nothing else.
Striving for simplicity and utilizing principles like single responsibility helps with this as complex things tend to hide issues much easier than simple ones.
Property based testing or fuzzing is an interesting tool to help aid in this. These tests are useful to generating a range of different inputs to find missing edge cases.
What can go horribly wrong here?
Always relevant when reviewing, writing code or designing a high level architecture.
Hopefully most of the time the answer is “nothing much”, but getting into the habit of thinking this way may occasionally reveal a “nothing much, unless…”
Asking this question can help guide us to identifying potential issues we wouldn’t otherwise notice, that can reveal exploits and worst case scenarios.
What would happen if it did actually go horribly wrong ?
This is a good question to ask to assist in limiting the blast radius of any worst case scenario.
Sometimes we will be able to make trade-offs that limit what an attacker could do in the worst scenario that they do hack us.
In the context of cross-site scripting attacks, this might mean we scrutinize more heavily the things we end up putting in
localStorage
andsessionStorage
.An attacker would have access to any sensitive or useful information stored in there.
A practical tip in this specific example would be to any store JWTs in cookies and using cookies with
HttpOnly
attribute set to make it inaccessible to Javascript.What would be some second order effects of things going horribly wrong?
One example of unintended second order effect, from OWASP, is a cinema web application that allows group bookings.
In the example given, people can make group bookings for cinemas, with a maximum of fifteen people before the application requires a deposit to be paid.
Utilizing this knowledge, an attacker could book up to six hundred seats at all cinemas, all at once in a few requests. This would prevent customers from booking seats and cause massive loss of income while the booking system allowed that.
While that seems like a pretty random example. It illustrates that to even think about these scenarios, we need to put ourselves in the seat of a (hopefully) fictional worst evil enemy. Which can help reveal potential vulnerabilities.
Asking these types of questions doesn’t guarantee we’ll have secure, bug free code. There’s no silver bullet. But it does take us a long way towards building more secure and resilient code than we would otherwise have.
While it’s important to have working knowledge of the different types of security exploits out there (check the references at the end of this article for more resources to build this up). Defending against those begins with having developed a security frame of mind from the very beginning.
Alright, with that out the way. Let’s now turn to a practical principles for dealing with the most common type of frontend security breach we are likely to encounter on the frontend.
It’s estimated that 60% of frontend security attacks are some sort of XSS attack. It’s up there as the top security risk in modern frontend applications. With even security focussed apps like Signal, falling prey to XSS vulnerabilities in their desktop application.
A quick XSS recap
We’ll start with the same origin policy. A foundational security principle that browsers implement.
The same origin policy prevents some random running their Javascript on our site without our permission. Which turns out to be pretty important for the web.
So attackers need to get their Javascript running on our pages some other way. Without proper security there’s a lot of ways they can do this.
If an attacker can achieve this, the game is usually over. They gain the ability to do whatever the user can do through their browser, controlled by the power of whatever Javascript can do in a browser.
So this includes things like getting sensitive credentials, sending any authorized requests, and all sort of other malicious things you can think of that can get them full system access.
A classic example of XSS, is the story of the guy who wrote the Myspace “Sammy worm”.
This was a script that got added to Myspace profile pages, that added him to their friends list, and top of their heros section.
”Once I was able to do that, I realized I was able to actually do anything on the page”
Any time someone visited a Myspace profile infected with the script, their profile would also be infected, leading to an rapid spread of his script across the Myspace site.
In this case the code was stored in the database as data and when rendered onto the page turned into code. Known as a stored XSS attack.
Let’s go over the principles that will allow us to develop frontend security XSS spidey senses to help avoid these kinds of attacks.
Developing XSS spidey senses
A good rule of thumb when working on the backend is to never trust anything that comes from the frontend.
Following our “healthy paranoia” principle, a good rule of thumb for the frontend is to never trust user generated data that comes from the backend.
It might sound extreme, but we can’t really know for sure if a user has somehow saved malicious data.
It also means even if it’s escaped properly today on the frontend, there is no guarantee it continues to be escaped down the line as things change and move around.
Whenever we render something to the page, or have to deal with any type of user generated input, it’s good to get those spidey senses going.
The obvious example is any type of form input that gets sent along with a http request to send data to the backend. But it also includes things like using query params in the url, headers, cookies and file and image uploads and the list goes on.
Most of the time our frameworks do a great job of this for us. Despite this there is no shortage of web apps being hacked.
Let’s now take a look at how this plays out when using a framework like React.
React XSS
React prevents a range of XSS attacks by default by escaping the strings it renders.
That’s one of the benefits of using a modern framework like React that abstracts the DOM away. So we have to worry about being being hacked every time we update the DOM.
This means if you’re using the standard patterns of React, you’ll be relatively safe.
But React comes with some escape hatches, and sometimes we have no choice but to use them. For example when we have no choice but to integrate with some other legacy system or library.
In these cases, it’s good to train our spidey sense on how things may go wrong, to prevent the most common types of XSS attacks:
Server side rendering with prefetched data
When server side rendering, a somewhat common pattern is to include pre-fetched data from the backend on the page.
This helps performance by saving the frontend from having to request that data after the client side Javascript has booted up. The data is already available on the page without needing to make a request.
As an example, you may have a super simplified HTML page that looks like:
`<!doctype html>
<html>
<body>
<!-- html string from React rendered on the server -->
<div id="root">${html}</div>
<script>
window.__INITIAL_DATA = ${JSON.stringify(prefetchData)}
</script>
<!-- ... more script tags down here etc -->
</body>
</html>`
Spidey senses alert. Remember we can’t really trust user generated data we get from the backend.
In this case we’re outside of the React context when we’re putting stuff from prefetchData
on the page.
It’s possible someone managed to save some data like:
{
name: "Cornelius Fudge",
description: "</script><script>alert('expelliarmus')</script>"
}
The main things to notice here, is that someone entered malicious html as data. Also that the malicious string starts with a closing </script>
tag.
When the browser renders the template above, it will see that as the closing tag to the first <script>
where we set __INITIAL_DATA
.
The browser will then go on to close that script tag, and render the malicious script tag instead to the page.
In our hand-rolled SSR example, we’re outside the safety of the React framework and so we are vulnerable to to XSS.
A simple fix for this is to encode it using a library like serialize-javascript
before rendering it on the page.
Even when inside the context of React, we are still vulnerable to XSS if we are not careful as we’ll see next.
Using dynamic content in JSX attributes
Let’s say we have a simple form that allows users to submit their favorite websites with name
and a url
properties. Somewhere in our code we then render a list of data:
data.map(({ name, url }) => (
<Card>
<a href={url}>{name}</a> // oh shi..
</Card>
))
This is a very common type of XSS attack. Brought to you by the javascript:
attribute that you can put in the href
attributes.
In this example a user can add a url that looks like:
<a href="javascript:alert(1)">damn</a>
Their script will run when the link is clicked. The javascript:
protocol is is a legacy protocol used on the web that is technically a feature, but resulted in a very common xss exploit.
React decided to keep that behaviour to align with the web. Other frameworks like Angular take a more opinionated stance and don’t allow it at all.
To React’s credit though, a warning does show if you write like this code, saying that it will be removed in future versions of React.
As browsers evolve and change, this highlights the battle between browser capabilities and features, and the potential exploits that can come as a result of those features.
So what’s the best practice to address this?
Back to our “healthy paranoia”, we can’t trust user input. If there is no other way but to render it on the page, so we need to sanitize things.
A naive approach would be to check for the presence of the javascript:
protocol and block it out.
However for any security related issues a blocklist approach is asking for trouble. Because we’re tempting someone to find a bypass somehow.
For example an attack could bypass the check for the javascript:
protocol using the data:
attribute which is also susceptible to the same kind of attack.
It’s usually better to default things like this to an allowlist, that only allow certain things and ignore everything else.
Compared to allowing anything but only certain things ignored, it’s much more robust.
Accessing the DOM directly
Using dangerouslySetInnerHTML
There are rare cases for this but they do exist. In terms of naming, React does a good job of making it clear it’s unsafe, especially with
{ __html: 'string' }
object you have to pass.This makes it easier to search for usages in a codebase, and will likely attract scrutiny in any pull request. Especially if the string is not being sanitized with a library like DOMPurify before hand.
Even so, sanitizing the DOM is a hard problem to solve in practice, and there are always people trying getting around it.
Even large companies like Google had issues with it, leading to Google search being vulnerable to XSS for five months. Which also highlights why it’s important to keep dependencies up to date.
In any case, if we do have to use
dangerouslySetInnerHTML
, we’ll want to sanitize with DOM Purify and utilize CSP and trusted types, which we’ll touch on in a bit.Utilizing element
refs
React provides escape hatches to manually access html elements either through refs or the
findDOMNode
function.This can be useful for doing things like manually managing focus
element.focus()
for example.By now it should be clear doing something like
ref.current.innerHTML = someContent
is a bad idea.Sometimes these cases aren’t as obvious as a simple
innerHtml
however. Cases like this can can sneak up in third party libraries, in their internal implementations.One example in React is using a markdown library to render html on the page. The issue here is you can put html into a markdown file and it is valid markdown.
When you depend on third party libraries for things like this it can be impossible to know without actually looking at the code to see if it’s vulnerable, even if the docs say they do things like sanitization.
One example from a markdown library with a previous vulnerability, wrapped links as it parsed them, having internal code along the lines like:
const renderer = new markdown.default.Renderer() renderer.link = function (href, title, text) { return '<a target=_blank" rel="noopener noreferrer" href="' + href + '" title="' + title + '"> + text + '</a>' }
The issue here again is rendering attributes without any sanitization. It’s the reflected
href
XSS vulnerability again.For any library that renders things things directly the page like a rich text editor, we should be extra cautious and spend time scrutinizing how it actually works.
Unfortunately if we don’t go through each line of our dependencies we should assume vulnerabilities exist with our dependencies.
Tools like
npm audit
can help somewhat, but they are noisy, and only report on known issues. Especially so since a lot of vulnerabilities onnpm
simply go unreported.There are newer tools like Socket that aim to protect against attacks from dependencies that is worth checking out.
With this in mind, it’s good to implement strategies that constrain what the browser is capable of doing that makes things more secure by default, which we’ll touch on next.
More defence against the frontend dark arts
Using a Content Security Policy
Browsers can’t really tell the difference between scripts downloaded from your origin vs another third-party origin.
Or put another way, once the browser has fetched and started processing the scripts, it’s all the same to the browser.
A CSP (content security) policy tells modern browsers which sources they can actually trust. In addition to what types of resources.
You can set specific polices within a CSP, for example only allowing images from a certain domain, or only allowing forms to submit to a specific domain.
CSP prevents our site from making requests to other sites. In the worst case this helps limit the damage an attack can do.
While CSP can provide good protection, configuring CSP is hard to get right and can lead to a false sense of security if not done by someone who really knows the ins and outs of effective policies.
Trusted Types
Trusted types aim to fix the most common root causes of DOM based XSS attacks. It works in combination with CSP to bolster it’s effectiveness against these attacks.
If we recall that most XSS attacks boil down to user inputted data as a string that ends up running as code inadvertently in the browser - when trusted types are enabled, the browser no longer accepts string based inputs to all the common browser API’s that put content on the page. For example APIs like innerHTML
, document.write
etc.
This in combination with CSP can provide great protection against XSS attacks.
When it’s enabled you have to use new trusted types that form the primitives for rendering content on the page safely.
Trusted types can be enabled through a CSP header or meta tag. This can also be run in report only mode to get a sense of what parts of your code, and your dependencies code have problematic areas.
They require a bit of set up through policies, where much more detail is available on the trusty MDN docs.
Recap
Frontend security is a hard topic, that is incredibly broad. We focussed on the underlying mental models that help us write more secure and resilient code by default.
From there we looked at the most common form of frontend security issue - XSS, and the most common ways it creeps in, even when using modern frameworks like React.
The main takeaway is to not get complacent. Developing a background process of a kind of “positive paranoia” can help with us stay vigilant and help us ask the right questions.
We also want to take advantage of all the security features browsers provide too. In the context of XSS protection, we looked at things like like properly configured CSP policies and newer browser capabilities like Trusted Types that further constrain and eliminate the surface area of XSS vulnerabilities.
We barely scratched the surface. If you’re interested in going deeper there are a number of resources I have found useful listed below.
References
- Catalog of cross site scripting attacks
- What’s really going on inside your node_modules folder?
- CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy
- Securing SPAs with trusted types
- The Stanford University web security course
- Cross site scripting prevention cheat sheet
- Second order effects
- Security considerations in Redux
- How To Protect Your App With A Threat Model Based On JSONDiff