Any authenticated Decidim user can accept or reject amendments on proposals they don't own, hijacking authorship. Affects versions 0.19.0 through 0.30.4 and 0.31.0.
# A Critical Flaw in Democracy Platforms Could Let Anyone Hijack Proposals
Decidim is software used by cities and organizations worldwide to let citizens vote on and shape local policies. Think of it like a digital town hall where proposals can be suggested, debated, and refined before a vote.
A serious flaw has been discovered that lets any logged-in user approve or reject proposed changes to these policies — even if they have no permission to do so. It's like discovering someone left the ballot box unlocked, and anyone could sneak in to rewrite votes.
The vulnerability is worse than it sounds. By rejecting or accepting amendments in the right way, attackers can trick the system into giving them official status as a "co-author" of proposals. This upgraded status brings real power — they can then control what changes get made and who sees what information.
Who's at risk? Cities using Decidim for participatory budgets, neighborhood councils, and policy decisions are vulnerable. Citizens in those communities could have their proposals silently modified by bad actors. Local governments might unknowingly implement changes that were tampered with.
The flaw affects Decidim versions released between early 2019 and September 2024, which means potentially hundreds of municipal platforms worldwide could be exposed. The good news: attackers haven't actively exploited this yet.
Here's what you should do:
First, if your city uses Decidim, ask your IT department if they've updated to the latest version (0.30.5 or later, depending on your release line).
Second, scrutinize any unexpected changes to proposals you care about. If a proposal mysteriously changed co-authors or had amendments approved without obvious discussion, report it.
Third, contact your local representatives and ask them to verify the integrity of recent participatory democracy decisions made through digital platforms.
Want the full technical analysis? Click "Technical" above.
CVE-2026-40869 is a broken authorization vulnerability in Decidim, the Ruby on Rails-based participatory democracy framework deployed by municipalities, universities, and civic organizations worldwide. Any registered and authenticated user — regardless of their relationship to a proposal — can invoke the amendment acceptance and rejection endpoints on proposals they do not own. The consequence is twofold: an attacker can silently alter proposal content by accepting attacker-controlled amendments, and the amendment author is automatically elevated to co-author of the original proposal via Decidim's coauthorship mechanism. This transforms a horizontal privilege escalation into a content-integrity and identity-spoofing primitive across the platform.
The vulnerability was introduced in version 0.19.0 when the amendments subsystem was added, and persists through 0.30.4 and 0.31.0. Fixed in 0.30.5 and 0.31.1.
Affected Component
The vulnerable surface lives in the amendments controller and its associated authorization layer inside the decidim-core gem. The specific files implicated by the patch are:
The amendments feature is enabled per-component (e.g., the Proposals component). When active, any user may submit an amendment to any proposal. The original proposal author is supposed to be the only party authorized to accept or reject those amendments. That invariant was not enforced.
Root Cause Analysis
The AmendmentsController exposes accept and reject actions. Prior to the fix, the Pundit policy backing these actions did not verify that current_user was the author of the amendable resource (the original proposal). It only verified that the user was authenticated.
# decidim-core/app/policies/decidim/amendment_policy.rb
# VULNERABLE (pre-0.30.5 / pre-0.31.1)
class Decidim::AmendmentPolicy < Decidim::ApplicationPolicy
def accept?
# BUG: only checks user is present — does NOT verify user == amendable.authors.first
user.present?
end
def reject?
# BUG: identical missing check — any authenticated user can reject amendments
user.present?
end
end
The controller calls authorize! amendment, :accept? via Pundit, which resolves to the policy above. Because user.present? is always true for any authenticated session, authorization is unconditionally granted.
# decidim-core/app/controllers/decidim/amendments_controller.rb
# VULNERABLE
def accept
@amendment = Amendable::Amendment.find(params[:id])
authorize! @amendment, :accept? # <-- resolves to user.present? — always passes
AcceptAmendment.call(@amendment, current_user) do
on(:ok) { redirect_to polymorphic_path(@amendment.amendable) }
on(:invalid){ render :show }
end
end
def reject
@amendment = Amendable::Amendment.find(params[:id])
authorize! @amendment, :reject? # <-- same broken check
RejectAmendment.call(@amendment, current_user) do
on(:ok) { redirect_to polymorphic_path(@amendment.amendable) }
on(:invalid){ render :show }
end
end
When AcceptAmendment runs, it merges the amendment's body into the original proposal and — critically — calls Coauthorable#add_coauthor with the amendment author, permanently attaching them as a co-author of the original proposal.
# decidim-core/app/commands/decidim/accept_amendment.rb
# VULNERABLE — coauthorship promotion with no privilege gate upstream
def accept_amendment
@amendment.amendable.update!(
body: @amendment.emendation.body,
title: @amendment.emendation.title
)
# Grants coauthorship to the amendment author on the ORIGINAL proposal
@amendment.amendable.add_coauthor(@amendment.emendation.authors.first)
@amendment.update!(state: "accepted")
end
Root cause:AmendmentPolicy#accept? and #reject? only assert user.present?, never validating that current_user is an author of the amendable resource, granting every authenticated session full control over every proposal's amendment lifecycle.
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker registers a legitimate account on the target Decidim instance (self-registration
may be open, or a low-privilege account is sufficient).
2. Attacker identifies a high-value proposal P owned by victim user V, with amendments
enabled on the Proposals component.
3. Attacker submits a malicious amendment A against P, injecting attacker-controlled
body/title content (disinformation, spam, or defamatory text).
4. Attacker issues authenticated POST to:
POST /assemblies//f//amendments//accept
with a valid CSRF token from their own session. No additional parameters required.
5. AmendmentsController#accept calls authorize!(@amendment, :accept?) which resolves
to AmendmentPolicy#accept? => user.present? => TRUE for any logged-in user.
6. AcceptAmendment command fires:
a. Overwrites proposal P's body and title with attacker content.
b. Calls P.add_coauthor(attacker_account) — attacker now listed as co-author of P.
c. Sets amendment state to "accepted".
7. Victim V's proposal now carries attacker content and attacker's name as co-author.
Public-facing proposal page reflects both changes immediately.
8. Repeat across any proposal on the platform with amendments enabled.
The attack requires no special tooling. A single authenticated HTTP request with a valid CSRF token (trivially obtained from the attacker's own session on the same origin) is sufficient. The CSRF token is not proposal-scoped.
This is a logic/authorization vulnerability rather than a memory-corruption primitive, so traditional heap diagrams do not apply. However, the ActiveRecord object graph state transition is the relevant "layout" to reason about:
OBJECT STATE BEFORE EXPLOIT:
Proposal P:
id: 1337
title: "Victim's legitimate proposal"
body: "Original content authored by V"
authors: [ UserV (id=10) ] <-- V is sole author
Amendment A:
id: 42
amendable_id: 1337
emendation: Emendation { body: "ATTACKER CONTENT", authors: [UserX (id=99)] }
state: "evaluating"
OBJECT STATE AFTER EXPLOIT (accept called by UserX, id=99):
Proposal P:
id: 1337
title: "ATTACKER TITLE" <-- OVERWRITTEN
body: "ATTACKER CONTENT" <-- OVERWRITTEN
authors: [ UserV (id=10), UserX (id=99) ] <-- ATTACKER INJECTED AS CO-AUTHOR
Amendment A:
state: "accepted" <-- IMMUTABLE, cannot be rolled back via UI
Once state is set to "accepted", Decidim's UI provides no built-in rollback. An administrator must directly manipulate the database to restore the original proposal content. The co-authorship record in decidim_coauthorships persists independently.
Patch Analysis
The fix in 0.30.5 and 0.31.1 corrects AmendmentPolicy to assert that the acting user is an author of the amendable resource, not merely authenticated:
# BEFORE (vulnerable — any authenticated user passes):
class Decidim::AmendmentPolicy < Decidim::ApplicationPolicy
def accept?
user.present?
end
def reject?
user.present?
end
end
# AFTER (patched — 0.30.5 / 0.31.1):
class Decidim::AmendmentPolicy < Decidim::ApplicationPolicy
def accept?
return false unless user.present?
# Verify acting user is an author of the original amendable resource
record.amendable.authors.include?(user)
end
def reject?
return false unless user.present?
record.amendable.authors.include?(user)
end
end
The patch is minimal and correct. record in Pundit policy context is the Amendment instance; record.amendable walks the association to the original proposal; .authors.include?(user) checks the decidim_coauthorships join table. Only users already listed as authors of the proposal can now trigger state transitions on its amendments.
No migration is required — the fix is purely policy-layer. Existing accepted amendments with injected co-authors are not remediated retroactively by the upgrade; administrators should audit decidim_coauthorships for anomalies dating from the vulnerable window.
Detection and Indicators
Because Decidim logs controller actions via Rails' standard logger, defenders can grep for anomalous amendment acceptance patterns:
DETECTION QUERIES (Rails log / SIEM):
# Identify accept/reject actions where actor != proposal author
grep 'amendments/.*/accept\|amendments/.*/reject' production.log \
| awk '{print $1, $2, $NF}'
# PostgreSQL: cross-reference coauthorships created near amendment acceptance
SELECT ca.decidim_author_id, ca.decidim_coauthorable_id, ca.created_at,
a.state, a.updated_at
FROM decidim_coauthorships ca
JOIN decidim_amendments a ON a.decidim_amendable_id = ca.decidim_coauthorable_id
WHERE a.state = 'accepted'
AND ABS(EXTRACT(EPOCH FROM (ca.created_at - a.updated_at))) < 5
ORDER BY ca.created_at DESC;
# Flag: coauthorship.created_at ≈ amendment.updated_at (same transaction)
# AND coauthorship author was NOT a prior author of the proposal
Indicators of compromise:
Proposals with co-authors who have no prior editing history on the proposal
decidim_amendments.state = 'accepted' records where decidim_coauthorships.decidim_author_id does not match the original proposal's author at creation time
Rapid sequential amendment acceptance across multiple proposals from a single user_id
HTTP 302 responses to POST /*/amendments/*/accept from users who are not proposal owners in application logs
Remediation
Primary: Upgrade to decidim gem version 0.30.5 or 0.31.1 immediately. The fix is a two-line policy change with no breaking API surface.
Workaround (if upgrade is not immediately possible): Disable amendment reactions at the component configuration level. In the admin panel, navigate to the Proposals component settings and disable Amendments. This removes the feature entirely, eliminating the attack surface at the cost of functionality.
Post-upgrade audit: Run the PostgreSQL query above against your production database. For any suspicious co-authorship entries, use decidim_versions (if PaperTrail is enabled) to reconstruct original proposal content. Manually remove illegitimate co-authorship records from decidim_coauthorships after confirming the anomaly.
Defense in depth: Rate-limit POST */amendments/*/accept and */reject endpoints at the reverse proxy level. These are low-frequency legitimate actions; more than ~5 requests per user per minute warrants alerting.