Vulnerabilities
Nine Case Files Reach Two Civic Halls — Decidim's July Security Repairs
Decidim's nine disclosures of 13 July 2026 tell one connected story: credentials, records, private files, query parts, stored pages, and push destinations reached a sensitive last step without all the organization, actor, grammar, or destination context that step required.

In this article
1 Nine envelopes arrived together, but they did not contain the same defect
1.1 The 13 July desk held nine different failures and one shared question
On 13 July 2026, an operator following Decidim's security page did not receive one dramatic bulletin. Nine advisories appeared after the project's disclosure grace period, each pointing to a different room of the platform: questionnaires, identity verification, organization-user search, exports, census administration, API identity, rich-text publishing, and Web Push. Read separately, they look like a crowded release note. Laid side by side, they form a case about information losing its escort on the final few steps.
Decidim is a Ruby on Rails framework for participatory-democracy services. One installation can serve more than one organization, and each organization can have its own hostname, participants, administrators, participatory spaces, verification records, content blocks, API credentials, and operational choices. Sharing an application and database is part of the design. The safety requirement is that every sensitive operation still knows which civic hall it is serving and which person is standing at the desk.
This report follows public project evidence only. We reviewed the three fixed release objects, all nine GitHub Security Advisories, the project's security policy, and the repair pull requests named by those advisories. We did not query a production Decidim installation, inspect a third party's identity documents, or send a test request to an external push service. The release objects identify which release lines carry the repairs, each GHSA defines its own range and severity, and the pull-request diff shows where the check enters the implementation; none substitutes for the others. Where the report joins several patches under one engineering lesson, that connection is our source-backed synthesis, not a vendor umbrella name for the disclosure, so every impact statement still returns to its own advisory and preconditions.
The nine files are CVE-2026-45086, CVE-2026-45330, CVE-2026-45376, CVE-2026-45377, CVE-2026-45378, CVE-2026-45414, CVE-2026-45415, CVE-2026-45572, and CVE-2026-45573. Two carry High severity in the public advisories: the reusable verification-document link and the organization-crossing JWT/API identity. The remaining seven are Medium. Severity tells an operator which source paths deserve urgent review, but local impact still depends on whether the relevant module is enabled, whether the path needs an ordinary participant or an administrator, whether reusable links or stored objects exist, and where the database and workers sit on the network. The files become easier to hold when each is named by the last operation that needed more context:
| Public file | Last sensitive operation | Missing decision | Primary repair |
|---|---|---|---|
| CVE-2026-45086 | Edit demographics questionnaire questions | May this actor update this component subject? | PR 16665 |
| CVE-2026-45330 | Open or decide a pending verification | Does this record belong to the active organization and handler? | PR 16666 |
| CVE-2026-45376 | Build the organization-user search order | Is every request value still data inside a closed query grammar? | PR 16668 |
| CVE-2026-45377 | Deliver a private personal-data export | May this signed-in person receive this exact file now? | PR 16680 |
| CVE-2026-45378 | Render an identity-verification image | May this participant or same-organization administrator view it now? | PR 16680 |
| CVE-2026-45414 | Resolve an API or JWT-backed user | Does the principal belong to the organization selected by the host? | PRs 16673 and 16756 |
| CVE-2026-45415 | Create, update, list, or remove CSV census data | Does the actor have the authorization permission for this action? | PRs 16674 and 16703 |
| CVE-2026-45572 | Render stored administrator-authored HTML | Has active markup been removed at the output cell? | PR 16451 |
| CVE-2026-45573 | Send a Web Push request to a stored endpoint | Is the destination approved at the moment of network use? | PR 16714 |
The table does not flatten the defects into “nine tenant bugs.” The SQL path is about query grammar. The stored HTML path is about output encoding and sanitization. The push path is about outbound destination control. What connects them is placement: a check existed elsewhere, or an object had already passed through a trusted layer, yet the function about to read data, change state, send bytes, interpret syntax, render markup, or open a connection lacked one fact it needed.
That placement explains why several reassuring facts can coexist with a real flaw. A user can be authenticated while carrying a principal from another organization. A blob URL can be cryptographically signed while no longer consulting the current visitor. One WHERE predicate can use binds while a neighboring ORDER BY expression interpolates the search term. A push endpoint can have been accepted months ago while remaining unsafe when a worker finally calls it.
The public sources establish vulnerable code paths and affected package ranges. They do not establish exploitation of a named installation, universal exposure of every dataset, or a common attacker campaign. Local response therefore starts with version and reachability, then advances only when identity, request, record, job, database, file, or egress evidence supports the next claim. That discipline protects both urgency and credibility.
For the rest of the story, imagine two controlled organizations on one test installation: the navy hall and the teal hall. Each has its own users, administrators, authorizations, content, exports, and credentials. The colors are fixture labels, not real municipalities or geographic claims, and every object is synthetic. Both halls travel through the same proxy, Rails application, database service, storage adapter, and worker topology so the test changes organization context without accidentally changing the whole system. Every repair will be asked the same practical question: when an object from the navy hall appears at a teal desk, which function notices, and does it notice before anything sensitive happens?
1.2 Three fixed release lines tell operators where the investigation starts
The first operational question is not “Do we run Decidim?” It is “Which Decidim packages, release candidates, forks, workers, and restored environments can execute one of these paths?” The release objects provide three convergence points: 0.30.9 for the seven repairs applicable to the 0.30 stream, 0.31.5 for the full July set on 0.31, and 0.32.0 for the repaired 0.32 release line.
| Release line | Fixed object | July scope | Operator reading |
|---|---|---|---|
| 0.30 | v0.30.9 | Seven applicable repairs | Demographics-question editing and the later organization/JWT behavior are not listed for this stream; still verify every installed package against its advisory. |
| 0.31 | v0.31.5 | All nine July advisories | Use 0.31.5 or a later supported release as the common target, then confirm every process loaded the same bundle. |
| 0.32 | v0.32.0 | All nine July advisories | Release candidates before 0.32.0 appear in advisory ranges; the final 0.32.0 release carries the repairs. |
Version 0.31.5 is the cleanest narrative center because its release notes name all nine security fixes together. It is still a bundle, not a magic number printed by the web tier. The release notes direct implementers through bundle update decidim, bin/rails decidim:upgrade, bin/rails db:migrate, and bin/rails data:migrate after the required prior patch line; record each command's artifact, output, and schema result instead of treating the tag as the procedure. The operator must verify the actual Decidim gems, source revision, container digest, migration state, and loaded worker image. A stale background process matters especially for stored push endpoints, where a request can be accepted by one process and used later by another.
Version 0.30.9 is narrower. Its release object contains the seven repairs that apply to that maintained stream; the demographics editor authorization and the organization/JWT issue begin in later code lines according to the release reconciliation. Copying a nine-row checklist onto 0.30 without this distinction creates false findings. Ignoring 0.30 because one advisory starts later misses the other seven.
Version 0.32.0 contains the July repairs and also mentions CVE-2026-44282, an item carrying a 6 September disclosure date. That file is outside this report's nine-case July scope. Keeping it separate avoids silently changing the incident set whenever a later release object accumulates another security note.
Each GHSA remains authoritative for its own affected package interval. Most list vulnerable ranges ending at 0.30.9, 0.31.5, or 0.32.0 and include release-candidate intervals where applicable. The demographics and JWT/API cases differ from the common pattern. A scanner or inventory query should therefore preserve package name and exact version, not reduce the whole installation to one top-level application string.
Decidim's security policy explains the timing. The project can publish a fixed release first, leave implementers a grace period, and disclose advisory details when that window closes; the policy assigns two months to ordinary cases and a longer interval to critical issues. A May deployment record and a July disclosure record therefore answer different questions. Keep four dates in the asset ledger—release publication, internal approval, actual rollout, and process restart—instead of using any one of them as a substitute for the others.
Keep immediate protection and historical evidence on parallel tracks. Immediate work upgrades the bundle, restricts risky functions during mixed-version rollout, and prevents old workers from claiming jobs. Evidence work preserves release manifests, authorization changes, custom content, push subscriptions, download access, query logs, and request-to-job correlations. Place preserved copies in an access-controlled evidence store, record collection commands, time ranges, and hashes, and keep document or subscription secrets out of the general incident channel. Cleaning stored objects before preservation may remove risk and also erase the reason a later tenant decision was made.
With the release lines pinned, the nine folders stop moving. The next step is to follow the first object through the hall: a credential that is perfectly valid in the navy organization and presented unchanged at the teal organization's host. The repair begins before any business resolver sees it.
2 The first key fit the lock, but the hostname belonged to the other hall
2.1 An API key must select a user inside the organization that received the request
At the navy hall, a system administrator creates an API user and receives a key and secret. The pair is valid. Nothing about the characters in the key announces a hostname, and the same Decidim process may answer for the teal hall a moment later. Before the repair, presenting the navy pair to the teal host could let authentication begin with a globally selected API user. The credential had proved that somebody knew a secret, but the lookup had not yet proved that the selected principal belonged behind this particular front door.
This is the important distinction in CVE-2026-45414: the defect did not require a forged credential. The dangerous object was an authentic credential used under the wrong organization context. Treating that situation as an ordinary password failure misses the architecture. In a multi-organization service, identity is not merely a user row; it is a user row resolved under the organization chosen from the request. The hostname arrives before the key lookup, so the finder has enough information to make that choice if the information is carried all the way in.
Pull request 16673 makes that carriage explicit. The Devise strategy changes its call from find_for_api_authentication(api_key: key) to find_for_api_authentication(api_key: key, env: env). The model then reads decidim.current_organization from the Rack environment. If that value is absent, the finder returns no user. If it is present, the authentication query contains two facts together: the supplied api_key and the active organization's id in decidim_organization_id.
The placement matters more than the number of changed lines. A later controller check would run after Devise had already materialized a principal and possibly after middleware had associated that principal with a request. By narrowing the database lookup itself, the repair ensures that an API user from the navy hall never becomes the provisional identity of a request to the teal hall. Authentication cannot succeed first and be corrected later; the wrong organization produces no resource for the secret validator to accept.
The upstream request specifications tell the same story without spectacle. They switch the test host to another organization and expect sign-in to return 403 Forbidden. They also create an API user in that other organization with the same key but a different secret, then confirm that the original pair still fails. In both cases the response must omit an Authorization header and must not contain a JWT token. Those negative assertions matter: a rejected body is not enough if a usable credential leaked through a header.
Once the fixed finder is loaded everywhere, the navy key stops at the teal entrance. But a second path remains inside the building. An API request can already carry a signed JWT or an established user session, and GraphQL still needs to decide which user it exposes to resolvers. The next patch repeats the organization question at that later handoff instead of assuming the earlier door was the only one.
2.2 A signed JWT still needs an organization check when GraphQL builds its user context
Imagine that the navy credential was used correctly at the navy host and a JWT was issued there. The token is not corrupted when it is copied into a request to the teal host; its signature can remain valid. That cryptographic fact answers who issued the token and whether its contents changed. It does not, by itself, answer whether the user represented by the token may become the current API user for the organization now serving the request.
Before pull request 16756, the API query controller's api_user method returned current_api_user || current_user. Either source could therefore supply a real user object before the controller compared its decidim_organization_id with current_organization.id. A GraphQL query such as the session query could observe a user context that belonged to another organization. The public evidence supports that cross-organization session behavior; it does not justify assuming that every resolver or every installation exposed the same fields.
The repair adds a small gate named organization_user. The candidate remains current_api_user || current_user, but it is returned only when a candidate exists, a current organization exists, and the two organization identifiers are equal. Otherwise the helper returns nothing. The controller memoizes that filtered result, so downstream context construction receives either a same-organization user or no API user at all.
Upstream tests exercise both ways a candidate can arrive. A controller test signs in a user from another organization, posts a GraphQL session query, and expects session to be null. A request test obtains an authorization value in one organization, switches the host to another, sends the same value to /api, and again expects a null session. The rejection occurs at context creation, before a resolver can treat the foreign user as local.
The API-key patch and the JWT patch are related but not interchangeable. The first narrows the credential-to-user lookup. The second filters an already materialized user before GraphQL exposes it. Keeping both checks is deliberate defense at two different handoffs: one covers API sign-in, while the other covers the query context produced from API or regular session state. Removing the later check because sign-in is fixed would assume that every future candidate can only enter through that one strategy.
4 The filter was parameterized; the sorting expression was quietly writing its own SQL
4.1 The dangerous term crossed into ORDER BY, beside predicates that looked safe
The story then leaves identity records and arrives at a small autocomplete box in the administrative interface. An administrator begins typing the name, email, or nickname of an existing participant to add to a conference or meeting. The visible task is ordinary: return matching users, rank the closest names first, and let the form store a selected ID. Underneath, PostgreSQL's trigram similarity functions were being asked to turn the search term into a ranking expression.
In the removed OrganizationController#users action, the relation itself began sensibly. A nickname search used where("nickname LIKE ?", "#{nickname}%"). Name and email searches used ILIKE ? with bound values and combined their relations. If a reviewer stopped at those predicates, the code looked parameterized. But ranking was constructed on the next line, and SQL has more input surfaces than WHERE.
For nickname searches, the controller interpolated the nickname into a string containing similarity(nickname, '...') DESC. For other searches it built GREATEST(similarity(name, '...'), similarity(email, '...')) and an averaged similarity expression the same way. The already-composed strings were passed through sanitize_sql_array and then wrapped in Arel.sql before reaching order.
Those names sound reassuring, but their position is decisive. There was no placeholder array left for Active Record to bind; interpolation had already merged request data with SQL grammar. Arel.sql then tells the query builder that a fragment is intentionally SQL. A helper cannot reconstruct the lost boundary between literal data and executable syntax after both have become one string. CVE-2026-45376 is therefore an ordering-expression injection, not evidence that the neighboring bound predicates failed.
The public advisory describes a blind, time-based path in the administrative organization-user search. That means a manipulated term need not make database rows or errors visible to create an observable effect; controlled delay can become the signal. Access to the affected administrative workflow is a prerequisite in the published case. It narrows who can initiate the request, but an administrative account is also close to personal data and high-value workflows, so the prerequisite should calibrate triage without erasing the risk.
Validation uses a disposable database populated with synthetic users, an application-only test role, and an explicit query timeout. Compare an ordinary term with a harmless quote and metacharacter corpus, inspect generated statements or bound-parameter telemetry, and destroy the database after each run. The acceptance result is structural: request values remain parameters or are processed by a supported search layer; they never appear as new functions, operators, comments, or clauses in logged SQL, and a request merely completing without error is not accepted as proof.
Historical review should start with reachability. Determine whether the removed JSON route existed in the deployed release and whether reverse-proxy, Rails, or database logs retain calls to it. Correlate authenticated administrator, organization, request ID, term length, response time, database role, and statement fingerprint, then establish normal autocomplete p50 and p95 baselines segmented by time and data volume. For an individual slow request, compare lock waits, connection-pool pressure, database load, and the statement duration on the same trace. Long response times alone do not prove injection; maintenance, lock contention, or slow similarity scans can look alike. Escalate only when unusual input shape, a stable delay signal, and query evidence agree.
The database role sets the ceiling for consequence. Record whether the Decidim connection could only read and write application tables or also held schema, extension, file, or administrative privileges. Review installed PostgreSQL extensions, callable functions, search paths, statement timeouts, and audit settings as well as grants; a role can inherit powerful behavior through its environment even when its name looks ordinary. Review row-level controls if a deployment adds them, and identify whether multiple organizations share the same role and schema. Repeat the check in preview, reporting, and disaster-recovery databases, where production-like data may coexist with looser roles. Least privilege does not repair query construction, but it converts an uncertain software path into a more bounded operational risk while the upgrade proceeds.
The lesson for review is unusually concrete: inspect every grammar-bearing method, not just filters. In Rails that includes order, reorder, select, group, joins, fragments passed to Arel.sql, and database-specific functions. A safe where on the line above is not inherited by its siblings. Each value must either remain data in a bind or be chosen from a closed application-controlled set.
4.2 The repair removes the bespoke route and sends autocomplete through the supported GraphQL search
PR 16668 does not try to salvage the similarity strings with another escaping call. It removes the users action from the organization controller and deletes the member route that exposed it. The form builder stops accepting and serializing a per-field searchURL. Conference and meeting forms no longer point at the retired endpoint. This is a meaningful reduction: the unsafe parser path disappears instead of remaining as an alternate interface that callers might rediscover.
The browser-side autocomplete now obtains Decidim's configured api_path and sends a JSON POST containing a GraphQL users query with a wildcard filter. The changed JavaScript escapes backslashes and double quotes before inserting the typed value into the GraphQL document, reads data.users, and maps each returned user to an ID and human label. If the request fails, the component returns an empty suggestion list; the removed JSON route is no longer a fallback.
Moving to GraphQL is not a magical property that makes arbitrary strings safe. The security value in this patch is that the particular raw similarity ORDER BY construction and its route are gone, while autocomplete converges on Decidim's supported user-search implementation. The supported resolver must still retain its authentication, organization filtering, complexity limits, and query-construction tests; changing the interface name does not retire those safeguards. Operators should test and adopt the fixed code as a unit. Copying only the JavaScript while leaving the old route callable would preserve unnecessary attack surface; deleting only the route while a custom form still depends on it would break administration and invite a hurried restoration of the old controller.
The upstream diff also reveals where local forks may diverge. It removes searchURL examples from the form builder, adjusts system tests to provide api_path, and updates conference and meeting pickers. Search custom engines, themes, and overrides for users_organization_url, searchURL, the old controller action, or copied similarity ranking code. A route can survive in a downstream engine even when the core route is gone.
Regression begins with normal work. From each affected administrative form, type three characters, select a synthetic participant from the current organization, submit the form, and confirm that the intended ID is stored. Verify that blocked, managed, deleted, or other-organization fixtures remain absent according to the supported resolver's policy, so the migration does not trade SQL safety for a broader participant list. Exercise names with apostrophes, backslashes, double quotes, Unicode, combining marks, percent signs, underscores, and long but reasonable input. The point is to preserve useful civic names while verifying that unusual text remains data throughout the request chain.
Then prove retirement. A request to the former organization-users route should not invoke the old search action, and no client bundle should contain its URL. Database statement telemetry during GraphQL search should show stable statement shapes and parameterized values produced by the supported resolver. If local GraphQL customization introduces dynamic sorting, review that code independently; convergence helps only when the destination remains maintained.
The archivist can now close the SQL folder with a precise statement: the repaired release removes a bespoke administrative endpoint whose ranking strings mixed request data with SQL syntax, and routes the feature through the supported GraphQL search. That is narrower and more useful than saying “input is sanitized.” It tells reviewers exactly which construction must be absent and which user journey must still work.
5 Some input waited in storage until a page rendered or a worker opened the network
5.1 Administrator-authored HTML is sanitized when the final cell turns it into a page
The fifth folder looks quiet because nothing happens when its content is saved. An administrator arranges an HTML block on an organization homepage, writes a static-page summary, or fills the left and right columns of a two-pane section. The text enters settings and may sit there through backups, translations, migrations, and release changes. The security event begins later, when a visitor opens the page and a view cell decides whether the stored string is text, approved rich content, or executable browser markup.
CVE-2026-45572 concerns four such rendering cells. HtmlCell#html_content, StaticPage::SummaryCell#content, StaticPage::SectionCell#content, and both column methods in TwoPaneSectionCell obtained a translated setting and called html_safe. In Rails, that call does not inspect the markup and pronounce it harmless. It marks the string as safe for insertion, telling the template layer not to escape it.
Administrator-only authorship changes the threat model but does not make the browser consequence disappear. An administrator account may be compromised, a database row may be restored from an older source, content may arrive through an import or local extension, or a role may be broader than the operator assumed. Once stored active markup is rendered on a popular civic page, it executes in visitors' origin context. The public case is stored HTML execution; local impact still depends on which cells are used, who could write them, and what browsers received.
PR 16451 replaces the five html_safe calls with decidim_sanitize_editor_admin. That is Decidim's sanitizer for administrator-authored editor content. Crucially, the call remains in the cell method after the translated value is selected. Every rendering pass therefore evaluates the content that will actually enter the page, including values stored before the upgrade. The repair does not rely only on a cleaner form submission that historic rows could bypass.
Sanitization is not the same as flattening every page to plain text. The added cell tests preserve supported structures: a paragraph with <strong>, emphasis with <em>, and a summary link still render. Separate fixtures include a script element or event-handler attributes such as onclick, onerror, and onmouseover; the resulting markup retains the safe text while omitting the executable tag or attribute. That paired test protects both safety and editorial function.
Multilingual settings deserve explicit fixtures. A safe English value does not prove that a previously imported Catalan, Spanish, French, or Chinese value passes through the same method, and a fallback locale can select content that the editor did not just display. Test a missing current locale, fallback to the default locale, a translation key that exists but is empty, and two-pane columns carrying different locale values. Populate every enabled locale with benign formatting and a nonexecuting marker that represents disallowed markup, switch locales, and inspect both the final DOM and actual response HTML; neither the database string nor an intermediate sanitizer return value can reveal what the browser ultimately receives.
Historic review should enumerate content blocks by manifest and scope, not run a blind search-and-delete over every angle bracket. Preserve a database snapshot or exported evidence under access control, identify the affected cell types, render them through the repaired code in a controlled environment, and record what the sanitizer changes. Hash the source row and rendered artifact, capture a DOM-level diff for removed tags or attributes, and map each block to its public URL, locale, publication interval, and available access logs. That produces a reviewable population without redistributing the active content. Destructive cleanup before preservation can remove a browser hazard while also erasing when the content appeared, which administrator last changed it, and which pages may have served it.
Local themes and cells can reopen the same path without touching the repaired files. Search overrides for html_safe, raw, direct calls to safe_join, or custom helpers applied to translated settings and editor fields. Review the last expression that returns markup, because an earlier sanitizer can be bypassed when a later helper concatenates new attributes or strings. Add the representative cell fixtures to the extension's own test suite, because core tests cannot cover code that the deployment has overridden.
Content Security Policy remains useful as an independent browser control, especially for reducing the reach of an unexpected script path, but it should not become evidence that unsafe markup is acceptable. Measure the real policy, nonces, allowed script sources, and reporting behavior; do not infer protection from a header name alone. The primary repaired behavior is simpler: active constructs are removed before the cell emits HTML, while supported editorial markup survives.
5.2 A Web Push endpoint is checked when written and checked again when a worker is about to use it
The neighboring folder contains no HTML at all. A browser asks to receive push notifications and submits an endpoint together with Web Push key material. Decidim stores the subscription under the user's notification settings. Hours or weeks later, a background job selects that record, builds a notification payload, and passes the endpoint to the Web Push library. The person who supplied the URL and the process that opens the connection may never share a request, log file, container, or deployment version.
CVE-2026-45573 arises from that delayed handoff. Without destination validation, a client-controlled endpoint can influence a server-side outbound request when a notification is sent. The public advisory classifies the result as server-side request forgery. Reach and consequence depend on the worker's network, proxy, DNS, metadata-service protections, and credentials, so a responsible review maps egress before claiming that a particular internal service was reachable.
PR 16714 adds a shared PushSubscriptionEndpointValidator. It rejects a blank value, parses the URI, requires a true HTTPS URI, normalizes the hostname to lowercase, and rejects a missing host. Anchored patterns then match push.services.mozilla.com, fcm.googleapis.com, android.googleapis.com, push.apple.com, opera.com, and notify.windows.com. Each expression accepts the exact domain or a genuine subdomain ending at the dot-delimited approved name; a lookalike such as good.example.attacker.tld fails the match.
The first invocation sits in NotificationsSubscriptionsPersistor#add_subscription. An unsupported endpoint raises a dedicated error before the subscription map is updated. The controller converts that error into an unprocessable-content response with an unsupported-browser message, and the browser-side code turns off the toggle and shows the error. The upstream test confirms both halves: a supported FCM endpoint is stored, while an example.org endpoint returns the error and leaves subscriptions absent.
Write-time validation improves new state, but it cannot speak for rows already in the database. It also cannot guarantee that another importer, console task, or older process did not write a record. The repair therefore includes the validator in SendPushNotification. Immediately before payload construction, the service filters user.notifications_subscriptions.values to supported endpoints. Only surviving subscriptions reach WebPush.payload_send.
The send-service test deliberately mixes one approved FCM subscription with one unsupported endpoint. It expects the Web Push library to be called exactly once, for the approved destination, and the returned response list to contain only that delivery. That is the crucial historic-state assertion: an unsafe value can exist in the fixture and still fail before network use. A migration that merely cleans today's database would not provide the same protection for a value reintroduced tomorrow.
An implementer may override the allowlist method to support another legitimate provider. That extension point is operationally useful, but it moves responsibility to the deployment. Document the exact domain family, business owner, vendor evidence, expiry or review date, DNS expectations, and network route before adding a pattern. Keep patterns anchored and dot-aware. A broad expression added to silence user complaints can turn a deliberate provider list back into arbitrary egress.
Application validation and network policy should agree without depending on each other. Permit background workers to reach the required push providers through an egress proxy or firewall, deny link-local and internal address ranges, and log destination host, resolved address where available, proxy decision, and request outcome. Resolution and connection can occur in different components, and a proxy may resolve the hostname again, so tests should correlate the same trace across application logs, proxy logs, and the actual connection. If an approved provider name resolves to an anomalous address, the network layer should still block it. The application decides what it intends to call; the network decides what the process is physically allowed to reach, and drift between the two configurations should alert.
Inventory is equally concrete. Extract endpoint hostnames without user key material, normalize and count them, separate approved families from everything else, and preserve the relationship to account, creation time if available, last delivery attempt, worker image, and organization under appropriate access control. Compare the observed host set with three independent sources: the validator patterns in the deployed artifact, the egress policy actually enforced for workers, and the documented provider list owned by operations. Any difference needs an owner and disposition, even when every individual hostname appears benign. An unknown hostname is a triage item, not proof of abuse. Correlate it with outbound proxy, DNS, job, and request evidence before drawing an incident conclusion.
The HTML and push repairs now meet at the same moment. Storage is not the end of validation. A value can be harmless while sitting in a row and dangerous only when a browser interprets it or a worker opens a connection. The last consumer must therefore repeat the decision in the vocabulary of its own risk: approved markup at render time, approved destination at send time.
6 Two invented organizations can retell all nine cases without touching anyone's civic data
6.1 Build one fixture where every legitimate action has a deliberately wrong twin
The navy and teal halls now leave the metaphor behind and become a real test fixture. Create both organizations in an isolated Decidim environment with synthetic names and no copied production records. Give each an ordinary participant and an organization administrator; add a process administrator whose narrower role is important to the questionnaire and census cases. Use separate hostnames that traverse the same host-resolution middleware as the deployment, even if both resolve to a local test server.
In the navy organization, create one API user, obtain one API sign-in response, and establish one regular session suitable for exercising GraphQL. In both organizations, enable the relevant authorization handlers and create pending ID-document and postal-letter records. Add a demographics questionnaire, CSV census import and individual census record, private export, verification attachment, restricted participatory-space document, HTML and static-page content blocks, and a push subscription map.
Make identifiers easy to correlate in evidence but impossible to confuse in the application. Record the fixture seed, object type, database ID, organization ID, owner ID, role, handler name, and state in a local manifest. Do not force the two organizations to share primary keys or secrets; the security tests should succeed against ordinary database behavior. Reuse an API key only in the specific same-key/different-secret case from the upstream test.
Every case begins with its valid control. The navy API pair signs in at the navy host. The navy JWT produces a navy session there. A navy administrator reviews a navy pending authorization, an authorized administrator edits the demographics questions and census, the export owner downloads the export, a same-organization administrator views the verification image, and a restricted-space member receives the document. Normal autocomplete, safe rich text, and an approved push endpoint also work.
Only after the controls pass should the wrong twins run. Change one fact at a time: keep the credential and switch the host; keep the host and use the other organization's record ID; keep the file link and change the actor; keep the actor and revoke membership; keep the filter but introduce unusual text; keep the stored row but change the renderer or sender. A single-variable change makes a failed test explainable and prevents one early rejection from hiding a later missing check. The working matrix is compact enough to review before execution:
| Case | Valid control | Wrong twin | Required observation |
|---|---|---|---|
| 45086 | Organization admin edits demographics questions | Regular or process admin attempts the same action | Denied action; questionnaire unchanged |
| 45330 | Local admin opens local pending verification | Local admin supplies foreign pending ID | Not found before decision command |
| 45376 | Supported autocomplete returns local participant | Former route and unusual grammar characters | Old action absent; stable parameterized statements |
| 45377 | Export owner downloads exact file | Another signed-in user reuses link | 404 and zero bytes |
| 45378 | Owner or same-organization admin views image | Foreign or ordinary nonowner reuses link | 404 and zero bytes |
| 45414 | Key and JWT work at issuing host | Same credential at other host | No principal, header, token, or session |
| 45415 | Organization admin manages census | Narrow process admin invokes each route | Denied; rows and imports unchanged |
| 45572 | Supported editor markup renders | Stored active tag or event attribute renders | Safe content remains; active construct absent |
| 45573 | Approved provider endpoint reaches stub | Historic unsupported endpoint enters send job | Unsupported destination never reaches client |
Run the table against the exact release candidate that will be deployed, not a developer branch that merely resembles it. If the service maintains 0.30, 0.31, and 0.32 streams, keep separate lockfiles and expected applicability for each. Label each skipped cell with the advisory range and package evidence that makes it inapplicable; an unexplained skip is indistinguishable from an unimplemented test. The 0.30 fixture should not manufacture the two cases that do not apply to that line, while all nine should be represented for the fixed 0.31.5 and 0.32.0 targets. Preserve results per line instead of letting the newest green build overwrite older maintenance evidence.
Workers belong in the same fixture. Queue a notification under one process image and execute it under the image intended for rollout; then reverse the order if mixed-version deployment is possible. Record the job serializer, queue name, enqueue time, digest of the process that claims it, and retry count. On every retry, verify that endpoint validation runs again immediately before sending and that the first filtering result was not frozen into the job arguments. Exercise generated private-download links through the same proxy and storage adapter used in staging. What matters is not only that the Ruby method passes in isolation, but that host, session, route, storage, job, and egress context survive the real handoffs.
The fixture should be disposable and repeatable. Seed it from code, generate fresh synthetic credentials on every run, capture versions and configuration, and destroy the database and storage objects afterward. Never import a production identity document “for realism.” A colored square or text file can prove attachment authorization just as well, without creating a second repository of sensitive civic information.
6.2 A rejection is proven by what did not happen after the response
An HTTP status is the first observation, not the verdict. A controller can return 404 after a command already changed a record, a background job can be enqueued before a redirect, and a proxy can replace an upstream response. For each wrong twin, define the forbidden consequence in advance: no selected principal, no authorization header, no session user, no state transition, no file bytes, no new census row, no unsafe DOM node, and no outbound client call.
Database telemetry helps distinguish lookup shape from final result. In the pending-authorization tests, confirm that the query contains organization, handler name, pending state, and ID; observing a missing row alone says nothing about how it was selected. In API authentication, confirm the key lookup also contains decidim_organization_id. For autocomplete, confirm the retired controller statement never appears and supported search values remain parameters. Statement fingerprints are often enough; avoid logging raw search text from real administrators.
Network tests need an observation point below the application service. Replace WebPush.payload_send with a strict test double or route staging egress through a controlled proxy. The unsupported historic endpoint must produce no client invocation and no DNS or proxy attempt. The approved provider-form fixture should produce exactly the expected call. This catches code that validates after the library has already resolved or contacted the destination.
Browser rendering needs the same last-step visibility. Inspect the produced DOM and relevant attributes after the cell renders, not merely the return value of a sanitizer helper. Confirm allowed formatting, links, and two-pane content survive. Confirm script elements and event-handler attributes are absent. If the deployment applies a custom Content Security Policy, keep that enabled for an integration pass but also test sanitization independently so one control does not mask the other.
Time should be treated as a test dimension for files and stored endpoints. Generate a private link, change the actor's relationship, and replay the link. Insert an unsupported subscription as if it predated the upgrade, then execute a post-upgrade send. Restore a pre-upgrade content row into the fixed build and render it. These cases demonstrate that the repair consults current policy at consumption, regardless of the date or process that wrote the object.
This laboratory cannot prove that an outside installation was exploited or clean. It proves that one identified build enforces the intended decisions under controlled conditions, and it reveals which production logs could answer historical questions. If a wrong twin fails, classify the observation before escalating it: incorrect fixture, deployment drift, missing instrumentation, a local override, or reproducible security behavior each leads to a different owner and next test. Preserve the failing trace and state snapshot before changing the environment. Keep that distinction in every report. Validation closes the engineering gate; incident assessment still depends on preserved local evidence.
7 The response succeeds only when code, running processes, stored state, and evidence move together
7.1 A fixed version is the first release gate, not the last line of the rollout
- The first gate is artifact integrity. Build once from reviewed sources, resolve the expected lockfile, run unit and integration suites, generate an SBOM or equivalent package manifest, and sign or otherwise attest the image according to local practice. Connect the source commit, lockfile SHA, image digest, compiled-asset digest, and deployment manifest in one traceable chain, then pull the final image back from the registry and inspect that object rather than trusting only the CI workspace. Verify that the private-download controller, organization-scoped authentication, sanitizer, push validator, and retired search route are present in the built object, not merely in the branch from which somebody intended to build.
- The second gate is process convergence. Roll web and workers with a plan that prevents old processes from accepting new work after the database and assets have moved. A new web process can reject unsupported push subscriptions while an old worker still sends a stored one; a new asset bundle can call GraphQL while an old server still exposes the removed JSON route. A readiness probe should verify the actual build identity rather than only an open port, while worker heartbeats, consumer inventories, or startup logs should prove that old instances have departed. Drain queues where necessary, stop schedulers from racing the change, and record each process start time, loaded digest, and last job-claim time after rollout.
- The third gate is behavior. Run the two-organization smoke subset through the production-like ingress using synthetic organizations or a dedicated staging environment: wrong-host API key, wrong-host JWT session, foreign pending record, denied questionnaire and census actor, wrong-user file link, retired search route, sanitized historic cell, and skipped historic push endpoint. The same-host controls must work. A deployment that blocks every request is not a successful security repair.
- The fourth gate is recoverability and evidence. Take tested backups of database, application configuration, and static or object storage as the release notes recommend, but record how sensitive exports and verification documents are protected inside those backups. Define rollback criteria before the window. If rollback would restore vulnerable code, pair it with temporary route, role, file, push, and egress restrictions so availability pressure does not silently reopen every path.
During a mixed-version window, reduce optional exposure. Pause or restrict the administrative user pickers if the old route remains reachable, delay nonessential Web Push delivery, and avoid generating new private exports through an uncertain path. Such measures are time-bounded rollout controls, not substitutes for the fixed release. Record who enabled them, what they cover, and the condition that removes them.
Close the release gate only when every process reports the intended artifact, migrations have the intended state, synthetic controls and wrong twins have passed, old routes are absent, and monitoring can see the fields required for follow-up. Query build identity from every load-balancer backend and worker pool, compare it with the deployment manifest, and retain the result beside the smoke-test trace. “Package upgraded” is a valuable milestone. It is not yet proof that the worker holding yesterday's job or the pod behind one forgotten service has joined the same story.
7.2 Durable records must be preserved, examined, and remediated by type
Code rollout changes what happens next; it does not rewrite every trace of what happened before. Start a parallel durable-state workstream with its own owners and access controls. Preserve the minimum evidence necessary to answer historical questions, document hashes and collection times, and then remediate according to data classification and retention policy. Do not place identity documents, exports, raw tokens, or push key material into a general-purpose incident ticket.
For private files, inventory private exports, identity-verification attachments, and restricted-space documents created during the affected window. Identify how their links were delivered and whether storage or CDN access logs can associate requests with an address, time, cache result, and response. Distinguish an origin-signed address, a CDN-derived address, and the final download URL copied from a browser because their revocation mechanisms and log locations may differ. The new controller protects newly generated private-download routes, but a previously issued Active Storage address may have its own validity and cache behavior. Evaluate those old addresses with the storage provider; deploying the repaired application does not revoke them by itself.
Remove expired exports and obsolete verification documents under the organization's retention rules after evidence preservation. If a link-signing or storage secret must be rotated, model its effects on sessions, unrelated signed objects, caches, queued mail, and disaster recovery first. Rotation is not a ritual response to every signed-link defect; it is justified when it meaningfully invalidates exposed material and the service can absorb the collateral effects.
For Web Push, extract and normalize endpoint hosts while excluding authentication and encryption key values from broad reports. Classify hosts against the adopted validator, locate creation and last-use timestamps where available, and correlate unsupported entries with worker, DNS, egress-proxy, or firewall records. Preserve a suspicious row before deletion. An unfamiliar endpoint may be a stale browser integration, a local provider, test data, or abuse; the surrounding evidence decides.
For stored HTML, enumerate only the affected core cells and their local overrides first. Preserve revision and administrator audit information, render a copy through the repaired sanitizer, and review changes. Remove or correct active constructs in the source rows when policy requires, but keep legitimate formatting intact. A wholesale strip of all HTML can damage public information and still miss a custom renderer that marks another field safe.
For identities and records, review API keys and JWT usage by organization, pending verification decisions, demographics-question changes, census imports and record edits, and administrator role assignments during the available window. Look for mismatches among routed organization, candidate principal, record organization, handler, and actor role. Do not label every cross-host request malicious; mobile clients, proxies, tests, and administrator mistakes need contextual review.
For the SQL path, preserve application, proxy, and database logs before normal retention removes them. Search for calls to the retired route, unusual term shapes, repeated latency patterns, database errors, and statement fingerprints, then correlate them with authenticated administrator and organization. Avoid replaying logged terms against production. Use the isolated lab if a suspicious string needs structural analysis, and restrict access because search terms may contain names or email fragments.
Each state class ends differently: a file may be expired, an endpoint removed, a page sanitized, a key revoked, a role corrected, a record decision reviewed, or a log gap accepted as an uncertainty. Record the object population examined, selection query, evidence retained, action taken, exceptions, reviewer, and completion time. Include caches, replicas, backups, exports, and legal holds in the disposition: deleting the primary row may not remove every controlled copy, and some copies may require sealed retention under policy or law. One “database cleaned” checkbox would erase precisely the distinctions that made the nine repairs understandable.
7.3 Report eligibility, reach, evidence, and consequence as four different numbers
The first executive question is usually “How many organizations were affected?” A defensible answer starts by declining to compress four denominators. Software eligibility counts instances or packages in an advisory range. Feature reach counts organizations where a vulnerable component and prerequisite were available. Observed evidence counts requests, objects, or jobs that match review criteria. Confirmed consequence counts cases where independent evidence shows an unauthorized read, change, execution, or outbound connection. Publish each numerator with its denominator, as-of time, collection method, and known blind spots; a number without that frame will be copied into later briefings as if it described all four populations.
Internal communication should name the exact behavior under review: a foreign-organization principal, an out-of-scope pending record, a missing action policy, a reusable private address, a raw sort expression, active stored markup, or an unsupported outbound destination. Pair each statement with evidence class, observation window, confidence, and the next fact that could strengthen or weaken it. Concrete language helps service owners find logs and users understand any needed notice. It also prevents the two High advisories from inflating every Medium case into the same consequence or a single missing log from being presented as proof of compromise.
The four gates leave the operator with a result that can survive scrutiny: known code, known running state, known stored-state treatment, and claims proportional to evidence. That is slower than changing a version string and faster than revisiting the same incident months later because nobody can tell which worker ran, which old link survived, or what “affected” meant in the original update.
8 The durable lesson is carried by the function that performs the sensitive act
8.1 The last consumer needs the whole sentence, not a trusted fragment of it
At the end of the day, the archivist returns the nine folders to their shelf. They do not become one vulnerability, and the project did not publish them under one shared technical label. Yet the repairs can be remembered as one engineering sentence: the function about to authenticate, load, change, deliver, interpret, render, or connect must receive every fact required to decide safely at that moment.
For API sign-in, the sentence is “this key selects this API user inside the organization resolved for this request.” A valid key alone is a fragment. For the GraphQL context, it is “this candidate user belongs to the same organization the current host selected.” A signed JWT alone is another fragment. Both repairs move organization agreement into the function that hands identity to the next layer.
For pending verification, the sentence contains organization, handler, pending state, and record ID before the row is selected. For questionnaire and census work, it contains actor, action, policy subject, and target before the command runs. A primary key and an authenticated administrator each sound authoritative in isolation; neither describes the permitted operation without the rest.
For private files, the signed GlobalID and attachment name identify an untampered reference, while the current actor and record-specific policy authorize today's delivery. For user search, the term remains data while the application owns the query grammar. In both cases, “signed” or “sanitized” is too vague to review. The useful question is what property was established, where, and whether it is the property the final operation needs.
For content cells, stored authorship does not survive as permission to execute arbitrary browser behavior; the renderer admits the supported editor vocabulary. For Web Push, a previously stored URL does not survive as permission to open any connection; the sender admits supported HTTPS provider hosts. Time and asynchronous work make these repeats necessary, because the actor, software, policy, or stored value may have changed since the write.
This approach also exposes weak abstractions during code review. A method named find_by_token may return a global user when callers need an organization-scoped principal. A method named download_url may encode integrity but omit actor policy. A value marked html_safe may mean only “do not escape.” A string passed to Arel.sql may mean “trust this grammar,” even when part of it came from a request.
Make those distinctions visible in interfaces. Pass organization explicitly or retrieve it from a clearly defined request context; return organization-scoped relations and never globally loaded records; separate signed reference parsing from authorization; give policy subjects stable names; accept sort choices as enums or application constants; return sanitized rich-content types; and make outbound clients accept validated destination objects where practical. Default branches should reject unknown policy subjects, attachment names, sort keys, and providers rather than treating compatibility as permission. Any extension must arrive with its policy, tests, and observable decision fields. Types and names cannot replace tests, but they make an omitted fact harder to overlook and show reviewers which layer owns the security decision.
The synthesis is deliberately modest. It does not claim that every Decidim feature lacked organization isolation or that all nine defects share one exploit chain. It offers a way to keep nine specific repairs connected without distorting their prerequisites and consequences. Each folder remains independently sourced; the shared sentence helps a reviewer find the next place where context could be dropped.
8.2 Turn the July fixes into a closure ledger that future extensions must keep passing
A closure ledger begins with the public facts: nine CVEs, nine advisories, three fixed release lines, the relevant repair pull requests, and the date each source was reviewed. Beside every case, record the local package range, reachable feature, prerequisite actor, repaired function, valid control, wrong twin, observable forbidden consequence, durable-state population, telemetry fields, response owner, and evidence-based outcome. Link each conclusion to an immutable source revision, test-run identifier, and controlled evidence location. If a later decision changes a state such as “applicable” or “confirmed,” preserve who changed it, the supporting basis, and who reviewed the change. This is more useful than a generic “patched” column and compact enough to survive handover.
Add source-review checks where they are reliable. Flag a tenant-owned model loaded by bare find(params[:id]) inside an organization controller; calls that hand current_user into API context without organization comparison; protected attachments rendered as direct blob URLs; interpolated request data inside grammar-bearing Active Record methods; stored editor content marked safe; and outbound clients fed from persisted URLs. Each flag needs human review, not automatic accusation.
Review time-changing objects explicitly. Which links should remain usable after logout, role change, membership removal, object deletion, or organization transfer? Which stored endpoints should be rechecked before every job? Which content should be sanitized on write, render, or both? Which credentials may be replayed under another host? Write the answers as tests with a state change between creation and consumption.
When a release changes routes or centralizes a service, prove both adoption and retirement. Confirm every caller uses the new path, the old route is absent from every running process, compiled assets no longer reference it, and local forks did not preserve a copy. The autocomplete repair is a model: consolidation lowers review cost only when the alternate implementation is actually gone.
The final scene is intentionally uneventful. A navy credential reaches a teal host and produces no user. A teal administrator enters a navy record number and finds nothing. A copied file ticket yields no bytes to the wrong person. Odd search text stays text; historic markup loses active behavior; an unsupported endpoint never reaches the network. The ordinary actions still work in their own hall. That quiet, repeatable result is what closes all nine folders.
Research record
9Evidence, objects, and sources
The material below preserves the identifiers and references used in this report.
9.1Research objects
Products, actors, techniques, affected objects, and control points discussed in the report.
The nine Decidim advisories publicly disclosed on 13 July 2026
Convergence points for the applicable July repairs; reconcile each package with its own advisory
Rack environment value consumed by organization-scoped API authentication
Compared during API-key lookup and GraphQL API-user context construction
MessageVerifier purpose for GlobalID and attachment-name references followed by live authorization
Sanitizer used by repaired HTML and static-page content cells
HTTPS and approved push-provider hostname check at persistence and delivery
9.2Event chronology
- Core repairs merged
The reviewed pull requests added action policies, scoped lookup, organization-aware identity, private delivery, sanitized rendering, and endpoint validation.
- Fixed release window opened
Decidim published the repaired 0.30.9, 0.31.5, and 0.32.0 release objects under its disclosure policy.
- Nine advisories became public
The project disclosed the nine CVEs, individual affected ranges, impacts, and repair references.
- SOSEC source review completed
The release objects, nine advisories, and the public implementation diffs were reconciled into this defensive report.
9.3Sources and material
- Decidim v0.31.5 release: all nine July security advisories and upgrade instructionshttps://github.com/decidim/decidim/releases/tag/v0.31.5
- Decidim v0.30.9 release: seven applicable July security repairshttps://github.com/decidim/decidim/releases/tag/v0.30.9
- Decidim v0.32.0 release: July repairs and separate later-disclosure reconciliationhttps://github.com/decidim/decidim/releases/tag/v0.32.0
- Decidim security policy: supported versions and disclosure grace periodhttps://github.com/decidim/decidim/security/policy
- GHSA-vq6j-hj8w-7v39: missing questionnaire-editor authorizationhttps://github.com/decidim/decidim/security/advisories/GHSA-vq6j-hj8w-7v39
- GHSA-86fh-w43w-338c: cross-organization verification-record accesshttps://github.com/decidim/decidim/security/advisories/GHSA-86fh-w43w-338c
- GHSA-jvqq-cvh4-xm37: organization-user search SQL injectionhttps://github.com/decidim/decidim/security/advisories/GHSA-jvqq-cvh4-xm37
- GHSA-767h-63j4-5226: reusable private-export linkshttps://github.com/decidim/decidim/security/advisories/GHSA-767h-63j4-5226
- GHSA-q79h-67vx-m9xg: missing CSV census authorizationhttps://github.com/decidim/decidim/security/advisories/GHSA-q79h-67vx-m9xg
- GHSA-3mvf-82qp-8qh5: reusable verification-document linkshttps://github.com/decidim/decidim/security/advisories/GHSA-3mvf-82qp-8qh5
- GHSA-r3v7-5x4c-c69q: JWT-backed user exposed across organizationshttps://github.com/decidim/decidim/security/advisories/GHSA-r3v7-5x4c-c69q
- GHSA-533c-2vh9-4r86: stored HTML execution in content cellshttps://github.com/decidim/decidim/security/advisories/GHSA-533c-2vh9-4r86
- GHSA-2g9c-vf8h-prxx: stored Web Push endpoint SSRFhttps://github.com/decidim/decidim/security/advisories/GHSA-2g9c-vf8h-prxx
- Decidim pull request 16665: questionnaire action permission and demographics subjecthttps://github.com/decidim/decidim/pull/16665
- Decidim pull request 16666: organization-scoped pending-authorization loaderhttps://github.com/decidim/decidim/pull/16666
- Decidim pull request 16668: remove raw organization-user search and adopt GraphQL autocompletehttps://github.com/decidim/decidim/pull/16668
- Decidim pull request 16673: bind API-key authentication to current organizationhttps://github.com/decidim/decidim/pull/16673
- Decidim pull request 16674: enforce CSV census controller permissionshttps://github.com/decidim/decidim/pull/16674
- Decidim pull request 16680: private-download controller and live record policyhttps://github.com/decidim/decidim/pull/16680
- Decidim pull request 16714: validate Web Push endpoints at persistence and sendhttps://github.com/decidim/decidim/pull/16714
- Decidim pull request 16756: filter GraphQL API user by current organizationhttps://github.com/decidim/decidim/pull/16756
- Decidim pull request 16451: sanitize HTML and static-page content cellshttps://github.com/decidim/decidim/pull/16451
- Decidim pull request 16703: enforce CSV census record create and update permissionshttps://github.com/decidim/decidim/pull/16703