XSS in The Digital #ClimateStrike Widget

Life keeps me busy, which is why this blog is seeing less and less publications. It's also the reason why I couldn't join the Global Climate Strike on September 20th. Friends have pointed me towards the Digital Global Climate Strike, where you can embed a script in your website and it will "go dark" on the strike day, drawing attention to the issue and calling people for action.

The widget waits until September 20th 2019 and expands to a big green banner that would block the whole page. Now that the strike is over, it's just a no-op.

mock-up of protesting web page

Many organizations and websites took part in the Digital Global Climate Strike. Among them: tumblr, wordpress, imgur, wikimedia, boingboing, kickstarter, greenpeace, bittorrent, change.org, Tor project and many many more.

list of participants from website

JavaScript and web security is my cup of teap so I couldn't help bug dig in. Maybe this would be a fun opportunity to contribute?

The websites also links to the digital-climate-strike GitHub repository and invites to collaborate. OK, let's give this a read. The file and commit id I am reading is https://github.com/fightforthefuture/digital-climate-strike/blob/0eb2621d0de84adbf9f8d88f99aa3b0e7486f7b9/static/widget.js

The source code starts with a list of settings that might be set on the parent page to allow the overlay be closed (or permanent) or redirect to a self-hosted copy of the widget and so on. iframeHost defines the hostname of the iframe that is being added to the page.

// …
  var DOM_ID = 'DIGITAL_CLIMATE_STRIKE';
  var options = window.DIGITAL_CLIMATE_STRIKE_OPTIONS|| {};
  var iframeHost = options.iframeHost !== undefined ? options.iframeHost : 'https://assets.digitalclimatestrike.net';
  var footerDisplayStartDate = options.footerDisplayStartDate || new Date(2019, 7, 1);       // August 1st, 2019 - arbitrary date in the past
  var fullPageDisplayStartDate = options.fullPageDisplayStartDate || new Date(2019, 8, 20);  // September 20th, 2019
// …

Below is a function getIframeSrc() that uses the iframeHost above. It includes a language check and forwards some of the options to the iframe itself (e.g., when disabling Google Analytics).

The next function createIframe() creates the actual element and sets the .src attribute according to the result of getIframeSrc() as defined previously.

Finally, it will add a message listener to get instructions from the iframe:

window.addEventListener('message', receiveMessage);

Let's look into this receiveMessage function:

function receiveMessage(event) {
  if (!event.data.DIGITAL_CLIMATE_STRIKE) return;

  switch (event.data.action) {
    case 'maximize':
      return maximize();
    case 'closeButtonClicked':
      return closeWindow();
    case 'buttonClicked':
      return navigateToLink(event.data.linkUrl);
  }
}

Wait a minute. The message does not have to come from the climate strike iframe. The function receives a message through the postMessage API and checks whether the received object contains a DIGITAL_CLIMATE_STRIKE attribute. But the website itself could be put into a frame and the parent website would then be able to send messages like this. Alternatively, if the website does not allow being put into a frame (and it should!), it could be opened as a new tab/popup and then receive messages. Depending on further message content, it will call the function closeWindow(), maximize() or navigateToLink(). The last one sounds most interesting. Let's follow along:

function navigateToLink(linkUrl) {
  document.location = linkUrl;
}

OK. This function is meant to be a simple redirect. However, the linkUrl is not being checked against a list of known URLs. We could just redirect to javascript: URLs. Navigations to javascript URLs are an old and weird way to execute JavaScript code. We have just found a Cross-Site Scripting bug.

Here's what an attacker would need to do: Just open the victim website in iframe or popup in a way which leaves you with a JavaScript handle for the tab/window. The XSS is then easily triggered with something like

handle.postMessage({
  DIGITAL_CLIMATE_STRIKE: true,
  'buttonClicked': 'javascript:alert(1)'
  }, "*");

To be clear, I found this bug a week before the strike and wrote a patch that fixes the issue. The patch adds two checks. First of all the receiveMessage() function gets a check that only allows for messages from the real iframe. The hostname was defined in iframeHost as explained in the beginning. Furthermore, we want to limit the navigateToLink function so it only allows visiting actual websites. The patched receiveMessage() function is therefore:

  function receiveMessage(event) {
    if (!event.data.DIGITAL_CLIMATE_STRIKE) return;
    if (event.origin.lastIndexOf(iframeHost, 0) !== 0) return;

    switch (event.data.action) {
      case 'maximize':
        return maximize();
      case 'closeButtonClicked':
        return closeWindow();
      case 'buttonClicked':
        if (event.data.linkUrl.lastIndexOf('http', 0) !== 0) return;
        return navigateToLink(event.data.linkUrl);
    }
  }

Kudos to the team for acknowledging and accepting the fix in a timely manner!


If you find a mistake in this article, you can submit a pull request on GitHub.

Other posts

  1. Fixing UNABLE_TO_VERIFY_LEAF_SIGNATURE... (Sun 01 December 2024)
  2. Modern solutions against cross-site attacks (Tue 26 November 2024)
  3. Prompt Injections and a demo (Wed 18 September 2024)
  4. The Mozilla Monument in San Francisco (Fri 05 July 2024)
  5. What is mixed content? (Sat 15 June 2024)
  6. How I got a new domain name (Sat 15 June 2024)
  7. How Firefox gives special permissions to some domains (Fri 02 February 2024)
  8. Examine Firefox Inter-Process Communication using JavaScript in 2023 (Mon 17 April 2023)
  9. Origins, Sites and other Terminologies (Sat 14 January 2023)
  10. Finding and Fixing DOM-based XSS with Static Analysis (Mon 02 January 2023)
  11. DOM Clobbering (Mon 12 December 2022)
  12. Neue Methoden für Cross-Origin Isolation: Resource, Opener & Embedding Policies mit COOP, COEP, CORP und CORB (Thu 10 November 2022)
  13. Reference Sheet for Principals in Mozilla Code (Mon 03 August 2020)
  14. Hardening Firefox against Injection Attacks – The Technical Details (Tue 07 July 2020)
  15. Understanding Web Security Checks in Firefox (Part 1) (Wed 10 June 2020)
  16. Help Test Firefox's built-in HTML Sanitizer to protect against UXSS bugs (Fri 06 December 2019)
  17. Remote Code Execution in Firefox beyond memory corruptions (Sun 29 September 2019)
  18. XSS in The Digital #ClimateStrike Widget (Mon 23 September 2019)
  19. Chrome switching the XSSAuditor to filter mode re-enables old attack (Fri 10 May 2019)
  20. Challenge Write-up: Subresource Integrity in Service Workers (Sat 25 March 2017)
  21. Finding the SqueezeBox Radio Default SSH Password (Fri 02 September 2016)
  22. New CSP directive to make Subresource Integrity mandatory (`require-sri-for`) (Thu 02 June 2016)
  23. Firefox OS apps and beyond (Tue 12 April 2016)
  24. Teacher's Pinboard Write-up (Wed 02 December 2015)
  25. A CDN that can not XSS you: Using Subresource Integrity (Sun 19 July 2015)
  26. The Twitter Gazebo (Sat 18 July 2015)
  27. German Firefox 1.0 ad (OCR) (Sun 09 November 2014)
  28. My thoughts on Tor appliances (Tue 14 October 2014)
  29. Subresource Integrity (Sun 05 October 2014)
  30. Revoke App Permissions on Firefox OS (Sun 24 August 2014)
  31. (Self) XSS at Mozilla's internal Phonebook (Fri 23 May 2014)
  32. Tales of Python's Encoding (Mon 17 March 2014)
  33. On the X-Frame-Options Security Header (Thu 12 December 2013)
  34. html2dom (Tue 24 September 2013)
  35. Security Review: HTML sanitizer in Thunderbird (Mon 22 July 2013)
  36. Week 29 2013 (Sun 21 July 2013)
  37. The First Post (Tue 16 July 2013)
π