Web Security Basics: XSS

posted on

I decided to learn more about areas of web development I don’t know a lot about. You know,…stuff like SEO and web security. I’ll share my findings here on my blog and I’ll try to do as much research as possible, but please keep in mind that I’m a noob concerning these topics.

I began watching Feross Aboukhadijeh’s fantastic Web Security lecture, which inspired me to learn more about web security. This first post is about Cross Site Scripting (XSS).

XSS

XSS describes the practice of injecting malicious code into an otherwise trusted website, or, in other words, injecting JavaScript into an HTML document. The point of XSS is that the attacker can execute JavaScript on a page by supplying untrusted data and do stuff they otherwise wouldn’t be able to do. For example, reading users’ cookies and sending HTTP requests with these cookies.

There are two types of XSS attacks: server and client XSS.

Server XSS

Server XSS occurs when the vulnerability is in the server-side code and the browser renders and executes the HTTP response generated by the server, which includes the user supplied untrusted data. For example, this can happen when a hacker exploits the comment field in a blog post by using it to save JavaScript in the database. This JavaScript code then runs every time anyone opens the page with the “comment”.

Client XSS

Client XSS occurs when the vulnerability is in the client-side code and untrusted user supplied data is used to update the DOM (Document Object Model) with an unsafe JavaScript call. For example, hackers could send out links with malicious GET parameters that inject JavaScript into the DOM when the user opens the link.

You can learn more about the different types of XSS and more detailed definitions on the owasp - Types of XSS page.

Hack the Planet!

Okay, now let me show how this may look like in practice.

A simple PHP search form

Let’s say we have a form that allows users to search for a custom term.

<form role="search" method="GET">
  <label for="term">Search term</label>
  <input type="text" id="term" name="q">

  <button>search</button>
</form>
A input field labelled “Search term” and a search button.

When the user enters a term and submits the form, we show the results and a message that says “Showing results for [TERM]”.

<?php 
  if (isset($_GET['q'])):
      echo "<p>Showing results for “<mark class=\"term\">".$_GET['q']."</mark>”</p>";
?>
  <ol>
    <li>Result 1</li>
    <li>Result 2</li>
    <li>Result 3</li>
  </ol>

<?php endif; ?>
A Search form that has been submitted showing some dummy results for the term “flowers”.

Since we’re using the GET method, the URL changes /search?q=flowers, too. This means that users can share the page with the search query pre-filled. This is where it gets dangerous because instead of searching for a simple string, someone could enter JavaScript.

A search form, not yet submitted, with “<script>Zer0 Cool was here</script>” as the search value

If you press Enter, PHP renders the code on the server which means that it also interprets the user supplied JavaScript. If you share the url /search?q=%3Cscript%3Ealert%28%22Zer0+cool+was+here%22%29%3C%2Fscript%3E with someone, the JavaScript in the GET parameter will run in their browser.

An alert with the message “Zer0 Cool was here” on the search page.

A simple JS search form

Now let’s try the same on a static page with JavaScript.

<!-- Search form -->
<form role="search" method="GET">
  <label for="term">Search term</label>
  <input type="text" id="term" name="q">
  <button>search</button>
</form>

<!-- Results, hidden by default -->
<div data-results hidden>
  <p>
    Showing results for “<mark class="term"></mark>”
  </p>

  <ol>
    <li>Result 1</li>
    <li>Result 2</li>
    <li>Result 3</li>
  </ol>
</div>

<!-- Get search parameters and show the results -->
<script>
  let params = (new URL(document.location)).searchParams;
  let q = params.get('q');

  if (q) {
    document.querySelector('[data-results]').removeAttribute('hidden')
    document.querySelector('input').value = q
    document.querySelector('.term').innerHTML = q
  }
</script>

You can see this in action in the simple JS search form demo.

If we try to search for a string that contains JavaScript <script>alert("Cereal Killer was here")</script> on this static page, the alert doesn't pop up. This is because the HTML string is injected and parsed, but the JavaScript doesn’t execute.

The script tag is visible in dev tools but the search term is empty and there's no alert.

This is by design:

When inserted using the document.write() method, script elements usually execute (typically blocking further script execution or HTML parsing). When inserted using the innerHTML and outerHTML attributes, they do not execute at all.

The script element

I was trying really hard “to make this work” and the only two ways I found were outdated or obviously wrong.

document.writeln() writes to a document and executes the code.

document.writeln(q)

The last time I've used document.writeln() or document.write() was more than a decade ago, but it's still good to know.

eval() would execute Javascript if you pass it directly like alert("Cereal Killer was here"), but you know what they say, eval() is evil, so don't use it.

document.querySelector('.term').innerHTML = eval(q)

Injecting a <script> tag in a static page seems to be harmless, but there are ways to execute JavaScript without using the <script> element.

Consider the following “search term”:
<img src="idontexist.jpg" onerror="alert('Acid Burn was here')">.

Here's what happens when someone tries to submit this search query: innerHTML injects the string and parses it as HTML. The browser tries to load idontexist.jpg, but fails because the image doesn't exist on the server. This triggers the onerror event which fires when a resource fails to load. The event then runs our JavaScript.

An alert with the message Acid Burn was here” on the search page.

Try it yourself in this demo.

XSS prevention

The are many things you should and could do to prevent XSS attacks. I suggest you read the XSS Prevention Rules on the owasp website. Most importantly, you should control where users can enter data and you should escape/HTML encode the data. This means turning critical characters like &, <, >,", and ' into entities. You can use a package like escape-html for that.

<script type="module">
  import { escapeHtml } from './escape-html.mjs';

  let params = (new URL(document.location)).searchParams;
  let q = params.get('q');

  if (q) {
    document.querySelector('[data-results]').removeAttribute('hidden')
    document.querySelector('input').value = q
    document.querySelector('.term').innerHTML = escapeHtml(q)
  }
</script>
A input field labelled “Search term” and a search button.

Try it yourself in the search form with escaped content demo.

Alerta Alerta

Most examples of XSS you find online use alert() to demonstrate vulnerabilities. You can’t do much harm with alert(), it’s just a nice way of showing that JavaScript can be injected. There are other dangerous methods you can execute, though. I’ll show you in the next blog post how XSS can be used to steal cookies.

As already mentioned, I'm pretty new to this topic. If I got something wrong or if I missed something, please get in touch via e-mail.