Closing vulnerabilities in Decidim, a Ruby-based citizen participation platform

This blog post describes two security vulnerabilities in Decidim, a digital platform for citizen participation. Both vulnerabilities were addressed by the Decidim team with corresponding update releases for the supported versions in May 2023.

| 14 minutes
This blog post describes two security vulnerabilities in Decidim, a digital platform for citizen participation. Both vulnerabilities were addressed by the Decidim team with corresponding update releases for the supported versions in May 2023. This blog post is not directly related to election security. Readers interested in election security should check out projects such as ElectionGuard or VotingWorks.

The world runs on software, and 97% of software projects include open source. It has truly built the backbone of so much of what powers our global economy, and vulnerabilities in an open source project can have a ripple effect across the entire ecosystem. As such, the GitHub Security Lab continually scans open source projects for vulnerabilities as part of our responsibility to help secure the open source ecosystem we all rely on. Using the same CodeQL technology that powers GitHub code scanning, we hunt for bug patterns that we know lead to exploitable vulnerabilities. We often find vulnerabilities in high-profile projects, and through our discoveries we are able to have a positive impact not only on the project, but on those who use the project in their software. This is the case for Decidim.

The affected project

Decidim is a platform for digital citizen participation where users can create initiatives and proposals. It also allows voting on or endorsing the submissions. Many different government organizations around the world use Decidim for digital citizen participation, including New York City and the European Union.

GitHub Security Lab used CodeQL for Ruby in combination with multi-repository variant analysis (MRVA) to scan popular repositories for cross-site scripting (XSS) vulnerabilities. We found one particularly impactful vulnerability in the results: a cross-site scripting vulnerability in Decidim’s external link feature. This would have allowed attackers to perform actions on behalf of logged-in users, potentially tricking citizens to support proposals without their consent. While most proposals voted on Decidim instances don’t directly become law, this vulnerability could have been used to undermine trust in participatory processes. We found the second vulnerability using a custom CodeQL query. The vulnerability allowed data exfiltration via query filters as long as a Decidim instance had the meeting component enabled. Both vulnerabilities were addressed by the Decidim team with corresponding update releases for the supported versions in May 2023.

For example, the initiative for extending the duration of maternity leave on the Decidim instance of the French national assembly (Assemblée Nationale), the lower house of the French parliament:

The first vulnerability (CVE-2023-32693—Cross-site scripting)

Decidim is a project based on the popular Ruby on Rails framework. CodeQL’s default Ruby query for reflected cross-site scripting identified a link_to XSS sink inside the Decidim core that is fed with data from user-controlled sources inside of new.erb.html:

<%= link_to t("decidim.links.warning.proceed"), params[:external_url], class: "button expanded primary" %>

The link_to URL helper is a special kind of XSS sink, since it allows linking to URIs using the javascript: scheme.

So, at first it looks like CodeQL identified a simple finding, where a user-controlled query param named “external_url” was fed directly into a “link_to” sink. But not so fast.

This new.erb.html page is part of the external link feature of Decidim that warns users before visiting an external website. The feature displays the URL to the user and highlights the hostname of the external web page. The actual link that the user will click on is directly coming from an attacker-controlled GET query parameter, external_url. The submitted URL is taken apart before being rendered using the following regular expression (Regex) in the LinksController:

parts = external_url.match %r{^(([a-z]+):)?//([^/]+)(/.*)?$}

However, this regex might have almost prevented the use of working URIs starting with the JavaScript scheme since it enforces a double slash after the colon. There’s one main problem with this regex—if you are a security-focused Ruby developer you might spot one issue immediately.

This regex splits the given URL in multiple parts and makes sure the URL contains two forward slashes (//). This prevents the direct injection of a working JavaScript URL, since the two-slash requirement transforms any JavaScript code that follows into a comment. Unfortunately, in contrast to other programming languages the default regex matching mode of Ruby is multi-line. The Securing Rails Application guide says the following about regular expressions in Ruby:

A common pitfall in Ruby’s regular expressions is to match the string’s beginning and end by ^ and $, instead of \A and \z.
[..]Ruby uses a slightly different approach than many other languages to match the end and the beginning of a string. That is why even many Ruby and Rails books get this wrong.[..] you always need to keep in mind that ^ and $ match the line beginning and line end in Ruby, and not the beginning and end of a string.

So, the regex shown above has to match on one line only. If we can smuggle additional lines through these lines of code, we can still render a JavaScript URL in the resulting HTML page. A working payload would look like this:

javascript:alert(document.location.host)//%0ahttps://securitylab.github.com

The %0a character marks the required newline for the payload to be smuggled through. The additional forward slashes before the new line are used to comment out everything coming afterwards from the perspective of the leading JavaScript payload. Since the full payload is matched by the regex shown above and the parts of its output are later used to display the URL to the user, the actual URL displayed to the user is the part after the new line (https://securitylab.github.com in this sample). Accordingly, the emphasized host is securitylab.github.com. If a Decidim user clicks on this link using the “Proceed” button the JavaScript is executed in the context of the actual website. This means the attacker can use the session of the logged-in user to perform actions on their behalf. Such an attack could have looked like this:

Screenshot: A JavaScript alert message is displayed to demonstrate that a cross-site scripting vulnerability exists. This alert was triggered when the user clicked on the “Proceed” button on a link provided by the attacker. Instead of the “malicious” attacker-supplied JavaScript URL, the user only sees what the attacker wants the user to see, in this case: a harmless link to securitylab.github.com.

Sidenote: circumventing the improved Regex

In the first attempt to fix this vulnerability, Decidim improved the regex by using string anchors instead of line anchors (in addition to parsing the URI with a library function). But is that enough?

parts = external_url.match %r{\A(([a-z]+):)?//([^/]+)(/.*)?\z}

Actually, no. Although the initial regex in Decidim was not meant to validate an URL, but to take it apart to display it to the user, one might think it’s enough to fix the regex in use with string anchors.

This, however, can still be circumvented by using the javascript: scheme directly and hiding the new line from the regex:

javascript://securitylab.github.com%250aalert(document.location.host)

This passes a URL directly with the javascript: scheme to the regex. The line break is double encoded (note the: %250a), so the regex doesn’t actually see a line break, but the characters %0a. However, this time we cannot hide the URL as elegantly as before from the preview view:

Screenshot: This time the attacker cannot hide the malicious URL from the user.

But the XSS vulnerability still works.

Manipulating votes via XSS: a more advanced attack scenario

But now, let’s make this even more interesting: an attacker could have used this vulnerability to make other users endorse or support proposals that they may have no intention of supporting or endorsing. A sample JavaScript code snippet to let a user support a specific proposal could look like this:

fetch("/processes/consequuntur-aperiam/f/12/proposals/8/proposal_vote",
{
  "headers":
  {
    "x-csrf-token": document.querySelector('[name="csrf-token"]').getAttribute("content"),
    "x-requested-with": "XMLHttpRequest"
  },
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
})

This JavaScript snippet extracts the CSRF-token from the current DOM and uses it to create a POST request to the Decidim proposal with the given URL. For this it uses the Fetch API supported by all modern browsers.

If we URL encode this payload it looks like this:

javascript:fetch%28%22%2Fprocesses%2Fconsequuntur%2Daperiam%2Ff%2F12%2Fproposals%2F8%2Fproposal%5Fvote%22%2C%20%7B%22headers%22%3A%7B%22x%2Dcsrf%2Dtoken%22%3Adocument%2EquerySelectorAll%28%27%5Bname%3D%22csrf%2Dtoken%22%5D%27%29%5B0%5D%2EgetAttribute%28%22content%22%29%2C%22x%2Drequested%2Dwith%22%3A%20%22XMLHttpRequest%22%7D%2C%22method%22%3A%20%22POST%22%2C%22mode%22%3A%20%22cors%22%2C%22credentials%22%3A%20%22include%22%7D%29//%0ahttps://securitylab.github.com

The attacker could have posted a link with such a Javascript URL as a comment or as part of a proposal. If the WYSIWYG editor (disabled by default) is enabled on the Decidim instance under attack the user could even use the link feature to better hide the, admittedly long and strange looking, link. In addition to that, such links could have been distributed via email to specifically target certain individuals, a process that works as long as the user is logged into the Decidim instance.

Screenshot: demonstrating a proposal that was created using the WYSIWYG editor and hides the malicious link behind the link text “Read more in my Blog.”

Remediation of the XSS vulnerability

A generic remediation advice for this and similar cases would be:

  • Parse the URI using a library and verify with an allow list that only URIs with http and https protocols are passed to the “link_to” helper. (for example, by checking the scheme with URI.parse(uri).scheme).
  • Fix the URL regex to use string anchors (\A and \z) instead of line anchors (^ and $).
  • Additionally, a Content Security Policy (CSP) can be created that disallows inlined JavaScripts. (Other parts of the application typically need modification to continue functioning.)

The Future of JavaScript URLs

There is an ongoing effort in the Chromium project with the aim of reducing the security impact of JavaScript URLs (URLs using the javascript: resource identifier). The idea is that the browsers would allow only commonly used JavaScript URLs such as javascript:void(0) or javascript:history.back() to be executed by default. Completely disabling JavaScript URLs in the browser is currently not an option as many websites still make use of JavaScript URLs in one form or another.

The second vulnerability (CVE-2023-34090—data exfiltration)

The second vulnerability we found allowed the exfiltration of certain data stored in the underlying relational database of Decidim if the meeting component was in use. Decidim uses a third-party Ruby library named Ransack for filtering certain database collections (for example, public meetings). By default, this library allows filtering using all data attributes and associations. This allowed an unauthenticated remote attacker to exfiltrate non-public data from the underlying database of a Decidim instance (for example,exfiltrating data from the user table).

Using a custom CodeQL query we discovered that user-controlled query filters can be passed into the filter parameter of the CalendarsController:

def show
  render plain: CalendarRenderer.for(current_component, params[:filter]), content_type: "type/calendar"
end

This filter gets passed through several classes, such as CalendarRenderer or BaseCalendar, before ultimately ending up in a ransack sink in the ComponentCalendar class:

def filtered_meetings
  meetings.not_hidden.published.except_withdrawn.ransack(@filters).result
end

This could allow a remote unauthenticated attacker to perform a brute force attack using specially crafted query filters via the meeting filtering functionality. For a more in-depth overview about this type of attack the blog post, Ransacking your password reset tokens by Positive Security, is strongly recommended. While the attacker cannot directly retrieve data via queries, the attacker can retrieve contents of database fields by (mis)using Ransack’s start(of) method. Using this filter an attacker can find out if a given string starts with a given character. An attacker can now guess the values of a database field character by character.

Using the start(of) method the maximum number of requests required for brute forcing a string of the length n with a character set of size m is m*n. In contrast to cracking a password we don’t have to try every possible combination;instead we can brute force the first character (if the character is part of the lower-case alphabet we need 26 attempts in the worst case), then we can move on to brute force the second character while using the first character as a prefix and so on. Depending on the collation of the underlying database it might not be feasible for an attacker to exfiltrate case-sensitive values in an acceptable time frame. In the case of the following proof of concept where we exfiltrate a meeting salt with a length of 64 and a character set size of 16 (0-9a-f) we’re talking of 1024 (64 * 16) requests in the worst case.

Proof of concept 1: exfiltrating the secret salt of a meeting

Decidim allows organizers of participatory processes to schedule meetings (for example, town halls). There’s a randomly-generated value associated with each meeting, a so-called “salt.” In this first proof of concept of the vulnerability we will simply exfiltrate the salt of the meeting. In the second proof of concept we will exfiltrate the email addresses of the meeting participants. As a precondition, at least one meeting needs to exist to be able to exfiltrate the salt.

We know that for a given process we can retrieve all meetings in a calendar format using a link like this:

https://<decidim-host>/processes/facere-qui/f/11/calendar

We can now add a Ransack filter to this URL:

curl "https://<decidim-host>/processes/facere-qui/f/11/calendar/?filter%5Bsalt_start%5D=0"

If this query returns a result, we can assume there’s at least one meeting that has a salt starting with 0. If it doesn’t return anything, we can assume that there’s no meeting that has a salt starting with 0 and can continue with the rest of the character set (0-9a-f). Within 16 requests we know the first character of a meeting salt. We can now query for the rest of the characters by adding more characters to the query filter. Using a script could speed up this process dramatically.

Proof of concept 2: exfiltrating the email address of a user

In this proof of concept, we exfiltrate an email address of a user that is participating in said meeting (via the user table). Again: at least one meeting needs to exist.

The data model for a meeting is associated with the user table via its registrations attribute.

This allows us to (mis)use this connection to retrieve the email address of a registered user, using a link such as:

curl "https://<decidim-host>/processes/facere-qui/f/11/calendar/?filter%5Bregistrations_user_email_start%5D=m"

Until we are in possession of the full email address.

curl "https://<decidim-host>/processes/facere-qui/f/11/calendar/?filter%5Bregistrations_user_email_start%5D=meeting-registered-user-14-1@example.org"

Sidenote: in the case of brute forcing an email address an attacker has to work with a bigger character set (for example, 0-9a-z.-_@ = 40) compared to the case with the salt. However, this is only true for the so-called local part of an email address (the part before the @-sign). In practice the domain part doesn’t have to be completely brute forced as many users use the same mail provider (for example, gmail.com, hotmail.com, etc.). This reduces the actual number of requests required in many cases.

If we automate the exfiltration of an email address by brute forcing it, it looks like this:

Remediation of the Ransack data exfiltration vulnerability

For projects that use the Ransack library to provide result filtering for remote users, the recommendation is to let ransackable_attributes and ransackable_associations default to an empty array and allow attributes and associations only as needed. Ransack 4.0 (released in February 2023) now requires explicit allowlisting of attributes and associations.

See also:

Conclusion

A combination of two footguns made it possible to exploit a cross-site Scripting (XSS) vulnerability in a project that is otherwise protected against XSS vulnerabilities due to proper output encoding:

  • Firstly, due to the fact that Ruby on Rails link_to helper allows links using the javascript: scheme.
  • Secondly (but less importantly, see: Circumventing the improved regex), thanks to the odd way Ruby handles regex it was possible to display a safe URL to the user.

While cross-site scripting vulnerabilities can have different impacts depending on the vulnerable applications, in the case of Decidim it would have been possible to trick users into endorsing or voting for proposals that might be completely against their interest. While proposals on the different Decidim instances are usually not directly enacted as laws, this vulnerability could have been used to undermine confidence in digitized participatory processes. The second vulnerability would have made it possible to extract certain sensitive data on Decidim instances with enabled meeting functionality, potentially revealing data (such as email addresses or last sign in IP addresses) that users wanted to keep private.


Disclosure timeline

  • 2023-01-17: We sent the report with the cross-site scripting (XSS) vulnerability to the Decidim team.
  • 2023-01-18: The Decidim team acknowledged receiving the report for the XSS vulnerability.
  • 2023-03-14: The Decidim team created a draft advisory and a fix for the XSS vulnerability.
  • 2023-03-21: We found the fix for XSS vulnerability to be incomplete and reported it back.
  • 2023-04-11: We reported the data exfiltration vulnerability via GitHub’s private vulnerability report feature.
  • 2023-04-20: The Decidim team created a pull request to fix the data exfiltration vulnerability.
  • 2023-05-11: New Decidim releases were published that fix the mentioned security vulnerabilities and an additional XSS-vulnerability (CVE-2023-34089) with the versions v0.26.7 and v0.27.3 (The CVE’s were mentioned on the release page and the releases were announced in the matrix channel of Decidim Developers/Implementers.)
  • 2023-07-11: The Decidim team released additional information about these vulnerabilities.

Written by

Related posts