In the world of web security, cross-site scripting (XSS) vulnerabilities are extremely common, and will continue to be a problem as web applications become increasingly complex. According to a 2016 report by Bugcrowd, a popular bug bounty site, “XSS vulnerabilities account for 66% of valid submissions, followed by 20% categorized as CSRF” (source). That same report goes into some detail about the severity of XSS (page 23), which is worth reading to understand the scope of this kind of attack.

Drupal developers are mostly protected from XSS by the HTML filtering system, but custom code can still allow attacks using unconventional exploits.

For a simple web application, finding an XSS vulnerability may seem trivial. Looking at online resources, you often find code snippets like this:

<h2>Search results for "<?php echo $_GET['q']; ?>":</h2>

but now-a-days, finding an exploit this obvious is unlikely, unless you’re looking at something completely hand-built. Drupal 8 makes code like this is even more unlikely, given that the templating system has moved to Twig, which escapes output by default and does not allow arbitrary PHP statements.

So what kind of Drupal code can trigger XSS? Let’s look at a practical example based on SA-CORE-2015-003, which was a security release I helped out on.

Note: The following exploit has already been fixed, don’t freak out!

Drupal has an internal AJAX system that responds to events on HTML elements, reaching out to a JSON endpoint to perform what’s known as AJAX commands. An example of this is the autocomplete element, which triggers AJAX requests as a user types to determine autocomplete suggestions.

You can enhance your HTML elements with this behavior like so:

  url: '/foo',
  event: 'click',
  element: $('#my-great-link'),

but to make things easier for developers, Drupal will automatically bind AJAX behaviors to any element with the “use-ajax” class. This may not seem like a big deal, but many Drupal sites allow users to input HTML, and classes are often allowed in default text filters.

To re-cap, this is what we know at this point:

  1. Drupal allows users to enter HTML by default in areas like comments
  2. The XSS filtering in Drupal is really good, so assume we can’t bypass it
  3. The class attribute is allowed in the default text filter
  4. The AJAX system automatically binds on elements with the “use-ajax” class

Knowing this, we can make a comment like this:

<a href="" class="use-ajax">Click me!</a>

and know that when the link is clicked, a POST request is made to an arbitrary URL, and Drupal will parse the response and process AJAX commands.

The xss.php script on our endpoint could look something like this:


// Only display the spoof response if the method is POST, this way normal users
// (i.e. non-admins) will see a normal page.
  // Allow any site to make a POST request here.
  header('Access-Control-Allow-Origin: *');
  // Form a bare-bones JSON response for Drupal.
  $foo = new stdClass();
  $foo->command = 'insert';
  $foo->data = '<script>alert("xss")</script>';
  $foo->method = 'append';
  $foo->selector = 'body';
  echo json_encode(array($foo));
// Display the normal page for GET requests.
else {
  echo "<h1>This is a normal site!</h1>";

Now when a user clicks our link, we can execute arbitrary Javascript in Drupal!

The Drupal security team has since improved the AJAX system by adding two layers of defense:

  1. When an AJAX-enabled element triggers its event (i.e. is clicked), Drupal verifies that the URL is local before making the request (see Drupal.url.isLocal).
  2. If a response does not contain a X-Drupal-Ajax-Token header, it is not processed. This prevents users from making local requests to uploaded files (see Drupal.Ajax.options.success).

This has been fixed for about two years, but it’s a great example of how complex XSS vulnerabilities in Drupal can be.

My biggest takeaway from this is that you don’t need to bypass the XSS filter to trigger XSS. Get creative with your research, and don’t hope to find quick-win exploits in code like:

{{ twig_input | raw }}

because it’s unlikely that you’ll get that lucky. 😁