漏洞

九份案卷抵达两座议事厅——Decidim 七月安全修复全景

Decidim 在 2026 年 7 月 13 日公开的九份公告,串起了一条完整线索:凭证、记录、私有文件、查询片段、存储页面和推送目的地走到敏感操作前,最后一步没有拿到所需的组织、访问者、封闭语法或目的地信息。

一座手绘剖面议事厅中,深蓝与青绿色两个组织分处不同楼层,记录、权限、查询、发布和投递依次经过办公桌,桌下整齐摆着九份封存案卷。
文章导航

研究依据复核 7 月 13 日披露的九个 CVE、官方修复版本、安全公告、修复 PR,以及双组织防御性回归模型

来源Decidim 安全团队 / Decidim 源码与修复 PR / SOSEC 一手资料复核

1 九个信封同一天送达,里面装的却不是同一种缺陷

1.1 7 月 13 日的桌上有九份案卷,也留下了同一个追问

2026 年 7 月 13 日,沿着 Decidim 安全页面查看更新的运营人员,没有等来一份耸动的总公告。项目的披露缓冲期结束后,九份公告同时公开,分别落在问卷、身份验证、组织用户搜索、导出、census 管理、API 身份、富文本发布和 Web Push 等不同房间。单独看,它们像一页拥挤的版本说明;并排摆开,线索却十分清楚:有些信息在走向最后几步时,身边少了本该陪同它的上下文。

Decidim 是一套面向参与式民主服务的 Ruby on Rails 框架。同一个安装可以服务多个组织;每个组织都能拥有自己的主机名、参与者、管理员、参与空间、验证记录、内容块、API 凭证和运营设置。共享应用与数据库是产品设计的一部分。真正需要守住的是:每个敏感操作发生时,程序仍然知道自己正在服务哪座议事厅,柜台前站着的又是谁。

本文只沿用项目已经公开的一手证据。我们核对了三个修复版本对象、九份 GitHub Security Advisory、项目安全策略,以及公告指向的修复 PR;没有测试任何外部生产环境,没有查看第三方身份文档,也没有把测试请求发往真实推送服务。版本对象负责回答修复落在哪条发布线,GHSA 负责各自范围与严重性,PR diff 则负责解释检查真正落在什么函数;三类来源不能互相替代。后文把多项补丁归纳为同一条工程线索,是建立在源码上的编辑总结,并不是厂商为这批漏洞给出的统一名称,因此每项影响仍要回到自己的公告和前置条件。

九份案卷分别是 CVE-2026-45086、CVE-2026-45330、CVE-2026-45376、CVE-2026-45377、CVE-2026-45378、CVE-2026-45414、CVE-2026-45415、CVE-2026-45572 和 CVE-2026-45573。公开公告中有两项为高危:可重复使用的验证文档链接,以及跨组织复用的 JWT/API 身份;其余七项为中危。评级能帮助运营人员确定先看哪些源码路径,却不能代替本地影响判断。每项还要分别核对是否启用相关模块、需要普通用户还是管理员、是否存在可复用链接或存量对象、数据库与 worker 处在哪个网络;只有这些条件与本地证据结合,严重性才会变成可以行动的优先级。把每份案卷按“最后一个需要更多信息的敏感操作”命名,九项修复就容易记住了:

公开案卷最后的敏感操作缺少的判断主要修复
CVE-2026-45086编辑 demographics 问卷问题当前人员能否更新这个组件对应的策略对象?PR 16665
CVE-2026-45330打开或处理待验证记录记录是否属于当前组织、当前处理器并且仍未通过?PR 16666
CVE-2026-45376构造组织用户搜索的排序每个请求值是否仍是封闭查询语法里的数据?PR 16668
CVE-2026-45377发出私有个人数据导出当前登录者此刻是否有权取得这一个文件?PR 16680
CVE-2026-45378显示身份验证图片当前参与者或同组织管理员此刻是否有权查看?PR 16680
CVE-2026-45414解析 API 或 JWT 对应的用户这个主体是否属于主机名选中的组织?PR 16673、16756
CVE-2026-45415新建、修改、列出或删除 CSV census 数据当前人员是否拥有这个动作对应的授权权限?PR 16674、16703
CVE-2026-45572渲染管理员保存的 HTML输出单元是否已经去掉活动标记?PR 16451
CVE-2026-45573向存储的 Web Push 端点发请求真正联网这一刻,目的地是否仍在允许范围内?PR 16714

这张表并没有把九项缺陷粗暴归成“九个租户漏洞”。SQL 路径关乎查询语法;存储 HTML 关乎输出清洗;推送路径关乎出站目的地。它们真正相连的是检查位置:有些值曾在别处通过检查,有些对象已经穿过一层可信流程,可就在函数即将读取数据、修改状态、发送字节、解释语法、渲染标记或建立连接时,少了自己需要的那一项事实。

这也解释了为什么一些看似令人安心的事实可以与真实缺陷同时成立。用户已经通过认证,所代表的主体却可能来自另一个组织;blob 链接带有有效签名,下载时却不再询问当前访问者;一个 WHERE 谓词使用绑定参数,旁边的 ORDER BY 却把搜索词插入字符串;推送地址几个月前曾被系统接受,worker 真正调用它时仍可能不合适。

公开来源能够证明受影响的代码路径和软件范围,却不能证明某个具名部署已经遭到利用,也不能证明每份数据都普遍外泄,更没有证据指向一场共同的攻击行动。本地处置应从版本与路径可达性开始,只有当身份、请求、记录、任务、数据库、文件或出口证据支持下一步时,再升级结论。这样既不拖延修复,也不消耗报告可信度。

接下来的故事里,我们用同一个测试安装虚构两座议事厅:深蓝厅和青绿厅。它们各自拥有用户、管理员、验证记录、内容、导出物和凭证。颜色只是 fixture 标签,不代表真实城市或地理判断,所有对象都是合成数据;两座厅还要经过同一套代理、Rails 应用、数据库服务、storage adapter 和 worker 拓扑,整套系统保持不变,测试只切换组织上下文。每项修复都回答同一个实际问题:当一个属于深蓝厅的对象出现在青绿厅柜台前,究竟由哪个函数发现,发现时敏感操作是否还没发生?

手绘档案员在木桌上整理九份封存案卷,另一份更晚公开的文件被单独留在侧边抽屉。
图 1|七月这一组共有九份公开案卷。它们适合放在同一张复核桌上,但每一份仍保留自己的受影响范围、触发条件、影响与修复证据。

1.2 三条修复版本线,决定运营人员从哪里开始排查

第一个运营问题不是“我们有没有用 Decidim”,而是“哪些 Decidim 包、预发布版本、下游分支、worker 和恢复环境能够走到这些路径”。三个发布对象给出了收敛点:0.30 分支适用的七项修复落在 0.30.9,0.31 分支九项全部收敛到 0.31.5,0.32 正式分支则以 0.32.0 为修复起点。

版本线修复对象七月范围运营解读
0.30v0.30.9七项适用修复demographics 问题编辑与后续组织/JWT 行为不在该分支清单内;仍须按各公告核对实际安装包。
0.31v0.31.5七月九份公告全部适用以 0.31.5 或更新的受支持版本为共同目标,并确认所有进程加载同一 bundle。
0.32v0.32.0七月九份公告全部适用0.32.0 之前的 release candidate 出现在公告范围中,正式版 0.32.0 携带修复。

0.31.5 最适合作为叙事中心,因为其发布说明把九项修复列在一起。但它依然是一组软件对象,不是 Web 层显示出来的一个神奇数字。发布说明要求在此前 patch line 已完成的前提下,依次运行 bundle update decidimbin/rails decidim:upgradebin/rails db:migratebin/rails data:migrate;每条命令的构建对象、输出和 schema 结果都应保存,不能把 tag 当成完整升级过程。运营人员还要核对真实安装的 Decidim gem、源码修订、容器 digest、迁移状态和 worker 当前加载的镜像。对于存储型推送端点尤其如此:一个进程可以接收订阅,稍后的另一个进程才负责使用它。

0.30.9 的范围更窄。发布对象包含这个维护分支适用的七项修复;根据版本和公告对照,demographics 编辑授权与组织/JWT 问题从后续代码线才开始出现。把九行清单原封不动套到 0.30 会制造误报;因为其中两项不适用就忽视 0.30,则会漏掉另外七项。

0.32.0 除了七月修复,还提到 CVE-2026-44282,而后者的公开日期为 9 月 6 日,不属于本文九份七月案卷。把它单独保留,能够避免发布对象后来累积新安全说明时,报告范围也在不知不觉中被改写。

每一份 GHSA 仍是自己受影响包范围的权威来源。多数公告把漏洞区间截止在 0.30.9、0.31.5 或 0.32.0,并在适用时列出 release candidate;demographics 与 JWT/API 两项与常见模式有所不同。因此,扫描器和资产查询都应保留包名与精确版本,不能把整套安装压缩成一个顶层应用版本字符串。

Decidim 的安全策略说明了为什么会出现两个时间点。项目会先发布修复版本,再保留一段缓冲期,让实施方有时间更新,期满才公开公告细节;策略为一般问题设置两个月缓冲,为 critical 问题设置更长窗口。于是,一条五月部署记录和一条七月披露记录回答的是不同问题:安全代码何时可用、运营方何时采用、公众何时获得足够调查信息,是三只不同的时钟。资产台账应同时保留 release published、内部批准、实际 rollout 和进程重启时间,不能用其中一个日期替另一个。

立即防护与历史取证应并行推进。前者负责升级 bundle、在混合版本窗口限制高风险功能,并阻止旧 worker 继续领取任务;后者负责保留发布清单、授权变化、自定义内容、推送订阅、下载访问,以及请求与任务之间的关联。保留副本应进入受控 evidence store,写明采集命令、时间范围和哈希,并让文档内容与 subscription secret 远离普通事件群聊。证据尚未保存就清理存储对象,可能既降低了风险,也抹掉了日后解释组织处置决定的依据。

版本线固定后,九份案卷终于不再移动。下一步,我们跟着第一个对象走进大厅:一枚在深蓝组织里完全有效的凭证,被原样提交到青绿组织的主机名。修复在任何业务 resolver 看见它之前就已经开始。

2 第一把钥匙能够开锁,可门牌却属于另一座厅

2.1 API key 必须在接收请求的组织内选择用户

深蓝厅的系统管理员创建了一个 API 用户,拿到 key 与 secret。这一对凭证确实有效,字符本身却不会写明主机名;同一个 Decidim 进程下一秒就可能替青绿厅响应。修复前,如果把深蓝凭证送到青绿主机,认证可能先从全局范围选出 API 用户。系统证明了来客知道某个秘密,却还没有证明这个主体应该出现在眼前这扇门后。

这是 CVE-2026-45414 最需要分清的地方:缺陷不要求伪造凭证,危险对象恰恰是一枚真实凭证,只是被放进了错误的组织语境。把它当成普通密码校验问题,会漏掉多组织架构的关键。在此类服务中,身份不只是用户表里的一行,而是“在当前请求选中的组织内解析出的那一行”。主机名早于 key 查询到达,程序只需要把这项信息一直带到 finder。

PR 16673 把这种传递写进了代码。Devise strategy 的调用从 find_for_api_authentication(api_key: key) 改为 find_for_api_authentication(api_key: key, env: env);模型再从 Rack 环境读取 decidim.current_organization。环境里没有组织就不返回用户;存在组织时,认证查询同时包含请求携带的 api_key,以及当前组织 id 对应的 decidim_organization_id

位置比改了几行更重要。若把组织检查留到后续控制器,Devise 可能已经实体化错误主体,中间件也可能已经把该主体与请求关联。现在,数据库查找本身就被缩进当前组织,深蓝厅的 API 用户不会先成为青绿请求的临时身份,再等后面纠正。组织不一致时,secret 校验器根本拿不到可接受的 resource。

上游请求测试没有制造戏剧性场面,却把预期写得很完整。测试切换到另一个组织的 host,要求登录返回 403 Forbidden;还在该组织创建一个复用相同 key、但 secret 不同的 API 用户,确认原凭证仍然失败。第二个用例专门排除了“只按 key 命中青绿用户,再拿深蓝 secret 继续校验”的错误回退;它证明查询必须同时包含组织,不能先查出用户再补判断。两种情况下,响应都不得带出 Authorization 头,也不得在正文里出现 JWT。只检查错误页面不够,如果 header 里已经漏出可用凭证,拒绝就没有完成。

当修复后的 finder 在所有进程中生效,深蓝钥匙会停在青绿入口。但楼内还有第二条路:API 请求可能已经携带签发好的 JWT 或现成用户会话,GraphQL 仍需决定把谁放进 resolver 上下文。下一项补丁没有假设第一道门是唯一入口,而是在这个更晚的交接点再次询问组织。

2.2 JWT 签名有效,GraphQL 构造用户上下文时仍要核对组织

设想深蓝凭证在深蓝 host 正常使用,并在那里签发了一枚 JWT。把 token 复制到青绿 host 后,它并不会自动损坏,签名仍可能完全有效。密码学事实能够回答 token 由谁签发、内容是否被修改,却不会自动回答另一个问题:它代表的用户,能否成为当前接收请求这个组织里的 API 用户。

PR 16756 之前,API 查询控制器的 api_user 直接返回 current_api_user || current_user。无论候选人从哪条路径进入,都可能在控制器比较其 decidim_organization_idcurrent_organization.id 之前,先形成一个真实用户对象。GraphQL 的 session 查询因此可能看见属于另一个组织的用户上下文。公开证据支持这一跨组织会话行为,却不足以推断每个 resolver 或每套部署都会暴露同样字段。

修复新增了一个很小的关口:organization_user。候选人仍是 current_api_user || current_user,但只有候选用户存在、当前组织存在,并且二者组织 ID 相等时,helper 才返回该用户;否则返回空值。控制器会缓存过滤后的结果,因此交给下游上下文构造的,要么是同组织用户,要么就没有 API 用户。

上游测试覆盖了候选主体的两种来源。控制器测试登录另一个组织的用户,提交 GraphQL session 查询,预期 sessionnull;请求测试则先在一个组织取得 authorization 值,切换到另一个 host,再把原值发送到 /api,结果同样必须是空 session。拒绝发生在上下文构造处,resolver 没机会把外来用户当成本地人。

API key 补丁与 JWT 补丁有关联,却不能相互替代。前者缩小“凭证查用户”的范围,后者在 GraphQL 暴露已经形成的用户前再次过滤。两道检查分别守在不同交接点:一道负责 API 登录,一道负责从 API 或普通会话状态构造查询上下文。因为第一道已经修好就删除第二道,等于假设今后所有候选主体永远只会经过一种 strategy。

手绘分隔柜台中,深蓝 API key 与 JWT 先核对深蓝组织,来到青绿柜台后被明确拒绝。
图 2|凭证可以真实有效,却不属于接收请求的组织。修复后的 API-key finder 与 GraphQL 用户上下文,在各自交接点都把身份与 host 选中的组织进行比较。

3 记录编号能拉开抽屉,真正决定手能否伸进去的是权限

3.1 验证、问卷和 census 动作,终于在动作发生处询问自己的策略

第二天早晨,测试环境里的青绿管理员收到一条 URL,其中带着某份待处理身份文档验证的数字 ID。编号格式正确,数据库里也确实有这一行;问题在于,它是深蓝参与者提交的记录。全局 Authorization.find(id) 能完美回答“这行是否存在”,却没有回答任何业务问题:它属于哪个组织、由哪种验证流程处理、现在是否仍待处理?

CVE-2026-45330 就藏在这里。身份文档的确认与拒绝控制器,以及邮寄信函的 postage 控制器,都按全局主键加载待验证记录。管理员已经登录,可记录 loader 没有把当前组织纳入对象选择。只要拿到另一行 ID,就可能把外组织 authorization 带进原本为当前议事厅设计的管理流程。

PR 16666 引入 PendingAuthorizationLoader。其中 load_pending_authorization! 先用三个条件建立 Authorizations 查询:organization: current_organization、预期处理器 name,以及 granted: false;relation 完整之后才调用 find(pending_authorization_id)。这与先执行全局 find、再在 Ruby 里比较字段不同:数据库从一开始就只会返回允许集合中的行,外组织 ID 直接落成 RecordNotFound。ID 仍是输入,但只能在已经许可的集合内选记录,也不会通过不同错误页面泄露“这条记录其实存在”。

处理器名称不是装饰。身份文档确认路由不应因为记录恰好共用一张表且都处于 pending,就误操作邮寄信函 authorization;granted: false 也能阻止专为未决记录设计的流程重新接管已经完成的授权。组织、工作流类型和状态由此成为查找语义的一部分,不再散落在对象加载后的各处。

修复测试创建第二个组织及其用户的 pending authorization,把请求当前组织设为第一座厅,再登录本地管理员。无论打开确认、提交拒绝,还是处理 postal-letter postage,都应抛出 ActiveRecord::RecordNotFound;系统测试把同样结果呈现为 404 页面。这是有价值的负向行为:响应既不确认外部记录存在,也不会让动作走到 command。

另外两份七月案卷并不是 loader 范围过宽,而是动作缺少授权。CVE-2026-45086 所在的共享 HasQuestionnaire concern,原本已在普通 edit 与 update 上执行 :update 权限,问题管理动作也需要同样检查。PR 16665 在 edit_questionsupdate_questions 中加入 enforce_permission_to(:update, permission_subject, questionnaire:),并让其他编辑路径复用这个可覆盖 subject。

正因为 subject 可以覆盖,同一个共享 concern 才能服务不同组件,而不用假装它们策略相同。默认值是 :questionnaire,demographics 问题控制器则返回 :demographics。demographics 权限测试允许组织管理员、拒绝普通用户;共享系统用例同时保留其他组件原有的 component-admin 行为。修复不是在视图里粘贴一条角色判断,而是在询问 Decidim 权限系统:“这个动作、这个对象,当前人员是否允许?”

CVE-2026-45415 通过两个 PR 把同一动作级思路补齐。PR 16674 为 CSV census 的 index、destroy、导入表单、创建导入和说明页加入 authorization 权限;复核过程中,PR 16703 又把相邻记录流程关上:new 与 create 强制 :create,edit 与 update 强制 :update。只读第一份补丁,会给运营人员留下一张不完整的修复地图。

上游测试把 process administrator 作为应拒绝角色,把 organization administrator 作为允许角色。这一点很重要:模块化公民平台里的“管理员”不是一种万能能力,一个人可以管理参与流程,却不应因此自动管理组织级验证数据。本地测试必须创建部署中真实存在的角色,逐个调用 index、form display、submit、update 与 destroy 动作,并同时核对响应、flash、command 调用、审计记录和数据库副作用。若记录已经变化、导入已经排队,或错误页透露了外组织对象,一次 redirect 或 404 并不能证明授权有效。

3.2 签名文件引用负责指向对象,实时策略负责决定谁能拿到字节

另一类记录会离开数据库,走进消息和浏览器。参与者向 Decidim 申请个人数据导出,或上传一张身份验证图片;随后,链接可能从邮件、浏览器下载列表或管理员审核页面被复制出来。普通 Active Storage blob URL 像一张 bearer 凭证:只要地址仍有效,持有者就可能直接访问存储路径,而最初绘制页面时认识参与者和组织的应用,已经不在最后下载点上。

CVE-2026-45377 对应私有导出,CVE-2026-45378 对应验证文档,后者在公开公告中评级更高。两项细节不同,PR 16680 却给它们建立了共同投递机制。Decidim 不再把受保护场景直接交给无需重新授权的 blob 地址,而是引入要求登录的 PrivateDownloadsController:解析签名引用、调用记录专属策略,再由 Rails 通过 send_data 输出文件。

新的 PrivateDownload token 刻意保持窄小。使用 JSON serializer 的 ActiveSupport::MessageVerifier:private_download purpose 下签名,payload 只包含记录 GlobalID 和请求的 attachment name。purpose 隔离防止同一份签名数据被当成其他 token 接受;attachment name 同样重要,因为一个模型可能挂着多个附件,策略必须能拒绝没有打算开放的那个名字。

签名有效只是第一关。from_token 校验 purpose、定位 GlobalID,并把缺失记录或无效签名转成 InvalidTokenError;控制器再检查指定附件确实处于 attached 状态,并调用 authorized_for?(current_user)。如果 GlobalID 所指记录已经删除、附件名不受模型支持、blob 已 detach 或当前用户关系变化,流程都必须在下载前停止。任何一步失败都返回 not found,避免用响应差异枚举对象。全部通过后,控制器才下载 blob,沿用真实 filename 与 content type,图片使用 inline disposition,其他内容作为 attachment 发出。

策略放在底层记录旁,是因为“私有”并不总是同一种关系。PrivateExport 只有在 attached_to 等于当前用户且 attachment 为 file 时通过;Authorization 允许记录所有者,或组织与 authorization 相同的管理员,并且只开放 verification_attachmentAttachment 只接受 file,再把人的权限判断交给受限空间的 can_participate?

正是这种分工,让重构不只是把一种难懂 URL 换成另一种。签名 token 表示“这些字节对应这条记录和这个附件名,引用没有被篡改”;记录策略表示“眼前这个人现在可以取得它”。当链接从深蓝参与者浏览器流到青绿账号,签名可以继续有效,策略仍会返回 false。引用完整性不会自动变成访问者授权。

上游控制器测试把这些差异写得很直白:私有导出对所有者成功、对其他用户失败;验证图片对同组织管理员成功、对不属于所有者的普通用户失败;受限参与流程中的 PDF 对成员成功、对非成员失败;无效 token 直接拒绝。这些是三条不同策略分支,不是一条笼统的“已经登录”。

通过 Rails 输出文件确实增加了数据路径和性能成本,PR 本身也明确承认这一点。它需要容量规划,却不是绕过敏感路由的理由。应使用合成文件测量并发大文件、慢客户端、代理缓冲、本地需要的 range 行为、内存压力、存储延迟、Web thread 占用和错误路径;发布前设好容量与 timeout 告警,并确认任何 CDN 或加速设计都在签发短时投递前执行等价的当前用户策略。随后扩展受保护路由,或选择能提供等价应用授权的存储机制;性能优化绝不能悄悄恢复可重复使用的 bearer link。

证据事件可以记录 private-download purpose、记录类型与 ID、附件名、actor 与组织 ID、策略结果、状态码和必要时的字节数,却不应保存文档内容或可重复使用的完整 URL。应用授权事件还要与 origin、object storage 和 CDN access record 对照:修复路由返回 404,证明 controller 拒绝了访问者,却不能单独证明此前缓存或直接存储地址没有发出字节。若历史 blob 地址已经进入邮件、日志或工单,升级只会改变未来链接生成,未必自动撤销每个旧存储地址。应单独盘点存储暴露与保留策略,不能假设新控制器能够抹掉过去。

手绘档案室里,一张签名文件票据先经过当前用户权限柜台,正确组织的封存文档才被取出。
图 3|引用选中精确记录与附件,实时记录策略再决定当前人员能否收到字节。下载发生时,两项检查缺一不可。

4 过滤条件使用了参数,排序表达式却在悄悄手写 SQL

4.1 危险搜索词进入 ORDER BY,旁边恰好都是看起来安全的谓词

故事随后离开身份记录,来到管理后台里一只很小的自动完成输入框。管理员开始输入现有参与者的姓名、邮箱或 nickname,以便把人加入会议或活动。表面任务平常得不能再平常:找到匹配用户,把最相似结果排在前面,再让表单保存选中的 ID。底层则调用 PostgreSQL trigram similarity 函数,把搜索词变成排序表达式。

在被删除的 OrganizationController#users action 里,relation 起步其实很规范。nickname 搜索使用 where("nickname LIKE ?", "#{nickname}%"),姓名与邮箱使用带绑定值的 ILIKE ?,再合并 relation。如果代码复核停在这些谓词,整个查询似乎已经参数化;问题出现在下一行的排名逻辑,而 SQL 输入面远不止 WHERE

nickname 路径把请求值插入包含 similarity(nickname, '...') DESC 的字符串;其他搜索则以同样方式构造 GREATEST(similarity(name, '...'), similarity(email, '...')) 和平均 similarity 表达式。拼好的字符串随后交给 sanitize_sql_array,再包进 Arel.sql 送到 order

这些方法名听上去很可靠,调用位置却决定一切。代码已经没有留给 Active Record 绑定的 placeholder 数组,请求值早已和 SQL 语法合成一个字符串;Arel.sql 随后又告诉查询构造器“这段内容就是有意写下的 SQL”。数据和可执行语法一旦混在一起,后置 helper 无法重新分开。CVE-2026-45376 因而是排序表达式注入,并不表示旁边的绑定谓词失效。

公开公告描述了管理端组织用户搜索中的时间型盲注路径。换言之,即使页面既不显示数据库行,也不返回错误,构造后的 term 仍可通过可控延迟形成信号。触发受影响请求需要具备对应管理功能,这是明确的前置条件;但管理账号本身更靠近个人数据和高价值流程,所以该条件应调节处置优先级,不能让风险凭空消失。

验证使用装有合成用户的一次性数据库、只具应用权限的测试角色和明确的 query timeout。对比普通词、无害引号与特殊字符语料,检查生成语句或绑定参数遥测,并在每轮后销毁数据库。验收结论必须是结构性的:请求值始终作为参数,或由受支持搜索层处理,绝不在 SQL 日志中变成新的函数、操作符、注释或子句,也不以“请求没报错”替代语句检查。

历史复核先看路径是否可达:部署版本中旧 JSON 路由是否存在,代理、Rails 或数据库日志是否仍保留调用。把已认证管理员、组织、request ID、term 长度、响应时间、数据库角色和 statement fingerprint 放在一起,并先用正常 autocomplete 建立按时段与数据量划分的 p50、p95 基线。对单次慢请求,还应核对锁等待、连接池、数据库负载和同一 trace 的 statement 时长。响应慢本身不证明注入,维护任务、锁竞争和 similarity 扫描都可能表现相似;只有异常输入形状、稳定延迟信号与查询证据等多项事实一致时,结论才应升级。

数据库角色决定潜在后果的上限。要记录 Decidim 连接是只能读写应用表,还是还拥有 schema、extension、文件或数据库管理权限;除 grant 外,还要核对已安装 PostgreSQL extension、可调用函数、search path、statement timeout 和审计设置,因为一个名称普通的角色也可能从环境继承强能力。若部署额外使用行级控制,也要核对其范围,并确认多个组织是否共用同一角色与 schema;preview、reporting 和灾备数据库也要重复,因为那里可能同时存在类生产数据与更宽角色。最小权限修不好查询构造,却能在升级期间把不确定的软件路径约束成更可控的运营风险。

代码复核留下的教训非常具体:检查每个能够承载语法的方法,而不只过滤条件。在 Rails 中,这包括 orderreorderselectgroup、join、交给 Arel.sql 的片段,以及数据库专属函数。上一行的 where 安全,不会把保护传给兄弟调用;每个值都必须留在 bind 的数据位置,或来自应用内部封闭选项。

4.2 修复删除专用路由,把自动完成交给受支持的 GraphQL 搜索

PR 16668 没有再尝试用另一种转义挽救 similarity 字符串,而是从 organization controller 删除 users action,并移除暴露它的 member route。form builder 不再接收和序列化每个字段自带的 searchURL,conference 与 meeting 表单也不再指向退役端点。风险面真正缩小了:不安全解析路径被彻底删除,不再留下一个随时可能重新被调用的替代接口。

浏览器端自动完成现在读取 Decidim 配置的 api_path,通过 JSON POST 发送带 wildcard filter 的 GraphQL users 查询。修改后的 JavaScript 会在把输入放进 GraphQL 文档前转义反斜杠与双引号,读取 data.users,再映射为 ID 和人类可读标签;请求失败时返回空建议列表,不会回退到旧 JSON 路由。

迁移到 GraphQL 并不意味着任意字符串天然安全。这份补丁真正的安全价值,在于那段 raw similarity ORDER BY 及其路由彻底消失,同时自动完成功能收敛到 Decidim 维护的用户搜索实现。受支持 resolver 仍要保留自己的认证、组织筛选、复杂度和查询构造测试,不能因为接口名称变了就停止审计。运营方要把修复作为整体采用:只复制 JavaScript 却让旧路由继续可达,仍保留多余入口;只删路由而让自定义表单依赖它,则会破坏管理功能,甚至诱使团队临时恢复旧 controller。

上游 diff 也指出了本地分支最容易偏离的位置:form builder 示例删除 searchURL,系统测试改为提供 api_path,conference 与 meeting picker 也随之更新。自定义 engine、主题和 override 都应搜索 users_organization_urlsearchURL、旧 controller action 以及复制来的 similarity 排名代码。core 路由消失,不代表下游 engine 没有留副本。

回归先从正常工作开始。在每个受影响管理表单里输入三个字符,从当前组织选择一个合成参与者并提交,确认保存的是预期 ID;还要按照受支持 resolver 的策略,确认 blocked、managed、deleted 与其他组织 fixture 没有出现在结果中,避免迁移在消除 SQL 风险的同时扩大参与者列表。再测试带撇号、反斜杠、双引号、Unicode、组合字符、百分号、下划线和合理长输入的姓名。目标是在保证异常文本始终作为数据的同时,别把真实公民姓名变成不可用的输入。

接着证明旧路径确实退役。对原组织用户路由的请求不应再调用旧 search action,任何客户端 bundle 也不应包含它的 URL;运行 GraphQL 搜索时,数据库 statement 遥测应显示稳定语句形状和受支持 resolver 产生的参数值。如果本地 GraphQL 定制又引入动态排序,那部分仍需独立复核,收敛只有在目的地继续受维护时才有意义。

至此,档案员可以用一句准确的话合上 SQL 案卷:修复版本删除了一个把请求数据混入 SQL 排名语法的专用管理端点,并把功能迁移到受支持的 GraphQL 搜索。这比“输入已经清洗”更窄,也更有用,因为它明确告诉复核者,哪种构造必须消失、哪条用户流程仍要正常工作。

手绘工作台把用户搜索卡片与固定 SQL 语法零件分开,一条不安全的手写排序纸带被从桌面移走。
图 4|参数化过滤器保护不了另行拼接的排序。修复移除原始排名路由,让请求文本留在受支持搜索路径的数据一侧。

5 有些输入先在存储中沉睡,直到页面渲染或 worker 打开网络

5.1 管理员保存的 HTML,要在最终 cell 把它变成页面时清洗

第五份案卷看起来很安静,因为内容保存的那一刻什么都没发生。管理员在组织首页摆放 HTML block,填写静态页面 summary,或编辑双栏 section 的左右两列;文本进入 settings 后,可能经历备份、翻译、迁移和多次升级。真正的安全事件发生在更晚的时候:访客打开页面,view cell 必须决定数据库字符串究竟是普通文本、允许的富内容,还是会在浏览器执行的活动标记。

CVE-2026-45572 涉及四种渲染 cell:HtmlCell#html_contentStaticPage::SummaryCell#contentStaticPage::SectionCell#content,以及 TwoPaneSectionCell 的左右列方法。它们取得翻译后的 setting,随后调用 html_safe。Rails 的这个方法不会检查标记并判断其无害,而是把字符串标成“可以直接插入”,告诉模板层不要再转义。

只有管理员能写内容,会改变威胁模型,却不会消除浏览器后果。管理员账号可能被接管,数据库行可能来自旧备份,内容可能经导入或本地扩展写入,角色范围也可能比运营方想象更宽。一旦存储的活动标记出现在热门公民页面,它就会在访客同源上下文中执行。公开案卷证明的是存储 HTML 执行;本地影响仍取决于用了哪些 cell、谁能写入,以及哪些浏览器收到页面。

PR 16451 把五处 html_safe 换成 decidim_sanitize_editor_admin,即 Decidim 专门处理管理员编辑器内容的 sanitizer。调用仍位于翻译值选定后的 cell 方法里,这一点十分关键:每次渲染都会检查真正要进入页面的内容,包括升级前已经保存的记录。修复并不只依赖一条更干净的新表单写入路径,让旧行可以从旁绕过。

清洗不等于把整页削成纯文本。新增测试明确保留受支持结构:带 <strong> 的段落、<em> 强调和 summary 链接仍能正常渲染;另一组 fixture 加入 script 元素或 onclickonerroronmouseover 等事件属性,输出保留安全文字,却不再含可执行 tag 或 attribute。安全与编辑体验必须成对测试,缺一边都会把修复做坏。

多语言 settings 需要专门的 fixture。英文值安全,不能证明先前导入的加泰罗尼亚语、西班牙语、法语或中文也经过同一方法;locale fallback 还可能选中编辑器眼前没有展示的内容。测试还应覆盖缺少当前语言、回退到默认语言、翻译键存在但为空,以及双栏两侧使用不同 locale 值的情况。应在每个启用语言里分别填入合法排版和代表禁止标记的无害测试记号,切换 locale 渲染,再检查最终 DOM 与实际响应 HTML;数据库字符串和 sanitizer 的中间返回值都无法单独说明浏览器最终看见什么。

历史复核应按 content block manifest 与 scope 枚举,不要对所有尖括号做盲目搜索和删除。先在受控环境保留数据库快照或导出证据,找出受影响 cell 类型,用修复代码渲染并记录 sanitizer 改变了什么;对源行和渲染 artifact 分别做哈希,生成删除 tag 或 attribute 的 DOM 级 diff,并把每个 block 对应到公开 URL、locale、发布时间段和可用访问日志。这样既能形成可审核总体,也不用重新分发活动内容。证据尚未保存就直接清理,可能既消除了浏览器危险,也抹掉内容何时出现、最后由谁修改、哪些页面可能曾经发出它。

本地主题和自定义 cell 即使不改已修复文件,也可能重新打开同类路径。应搜索 override 中的 html_saferaw、直接 safe_join,以及应用在翻译 setting 和编辑器字段上的自定义 helper。重点看最终返回 markup 的表达式,因为早期 sanitizer 之后若又拼接新属性或字符串,仍可能被绕开。扩展自己的测试套件必须保留这些代表性 fixture,不能假设 core 测试会覆盖 override。

Content Security Policy 仍是有价值的独立浏览器控制,尤其可以缩小意外脚本路径的影响,但不能被当成接受不安全标记的理由。要测量真实策略、nonce、允许脚本来源和 report 行为,不能只看见一个 header 名称就推断已经防住。主要修复行为更直接:cell 发出 HTML 之前移除活动构造,同时保留编辑器支持的排版。

5.2 Web Push 端点写入时检查,worker 真正使用前还要再检查一次

旁边那份案卷里没有 HTML。浏览器申请接收推送通知,提交 endpoint 和 Web Push key material;Decidim 把 subscription 存进用户 notification settings。几小时甚至几周后,后台任务选出这条记录,构造通知 payload,再把 endpoint 交给 Web Push 库。提供 URL 的人和真正联网的进程,可能从未共享过同一个请求、日志文件、容器或部署版本。

CVE-2026-45573 就产生于这次延迟交接。目的地没有校验时,客户端控制的 endpoint 会在通知发送时影响服务端出站请求,因此公开公告把它归类为 SSRF。具体可达范围与后果取决于 worker 所在网络、代理、DNS、metadata service 防护和进程凭证;严谨复核必须先画清出口,不能直接宣称某个内网服务一定可达。

PR 16714 新增共享的 PushSubscriptionEndpointValidator:空值拒绝,URI 解析后必须是真正的 HTTPS,host 统一转成小写并且不能为空;随后,用锚定正则匹配 push.services.mozilla.comfcm.googleapis.comandroid.googleapis.compush.apple.comopera.comnotify.windows.com。每条规则都从字符串开头锚到结尾,并以可选的“任意子域加点号”开头,因此允许确切域名或真正子域,不会把 good.example.attacker.tld 一类仅仅尾巴相似的 host 当成供应商地址。

第一次调用位于 NotificationsSubscriptionsPersistor#add_subscription。不受支持的 endpoint 在 subscription map 更新前抛出专用错误;控制器把它转换为 unprocessable-content 响应和“浏览器不受支持”消息,前端再关闭 toggle 并显示错误。上游测试同时验证两边:合法 FCM 地址能够保存,example.org 地址返回错误,subscriptions 仍为空。

写入时校验可以改善新状态,却无法替升级前的数据库行说话,也不能保证 importer、console task 或旧进程从未写过记录。因此,修复把 validator 同时 include 到 SendPushNotification。就在构造 payload 之前,service 先过滤 user.notifications_subscriptions.values,只有通过的 endpoint 才能进入 WebPush.payload_send

send service 的测试故意混入一条合法 FCM subscription 和一个不受支持 endpoint,要求 Web Push 库只被调用一次,而且仅针对允许目的地;返回列表中也只能有这次投递。这条历史状态断言最关键:即使 fixture 里已经存在危险值,它仍会在联网前停止。只清理今天数据库的 migration,无法为明天再次写入的值提供同样保护。

实施方可以 override allowlist 方法,以支持另一个合法供应商。扩展点很实用,责任也随之转移到部署方:新增 pattern 前,应记录精确域族、业务负责人、供应商依据、到期或复核日期、DNS 预期和网络路径;规则必须保持锚定与点号感知。为了消除用户报错而添加的宽泛正则,很容易把刻意维护的供应商清单重新变成任意出口。

应用校验和网络策略应该互相一致,却不能彼此依赖。让后台 worker 只能通过 egress proxy 或防火墙访问所需推送服务,拒绝 link-local 与内部地址范围,并记录目的 host、可用时的解析地址、代理决定和结果。解析与连接可能发生在不同组件,代理还可能重新解析 DNS,因此测试要从应用日志、代理日志和实际连接三处核对同一 trace;供应商域解析出异常地址时,网络层仍应阻断。应用负责表达“我打算访问哪里”,网络负责约束“这个进程物理上能去哪里”,两边配置漂移都应触发告警。

资产盘点也应同样具体:在不导出用户 key material 的前提下提取 endpoint hostname,规范化后统计,把允许域族与其他地址分开,并在受控范围关联账号、可用的创建时间、最后投递尝试、worker 镜像和组织。把观测 host 集合与三份独立来源比较:部署 artifact 里的 validator pattern、worker 实际执行的 egress policy,以及运营方维护的供应商清单。三者只要不一致,即使每个域名看似正常,也要有 owner 与处置结论。未知 host 是待调查对象,不是滥用证据;只有与出站代理、DNS、job 和请求事实相互印证,才能升级结论。

HTML 与推送修复终于在同一个时间点相遇:写进数据库并不代表验证已经结束。某个值躺在一行里时毫无动静,只有被浏览器解释或被 worker 用来联网时才产生危险。因此,最后的消费者必须使用自己真正理解的语言再做一次决定:渲染时只接受允许的 markup,发送时只接受允许的目的地。

手绘档案分别把存储 HTML 送过页面清洗台,把存储推送端点送过目的地检查台,之后才允许它们抵达浏览器或网络。
图 5|存储值有不同的最后一公里:一个会被浏览器解释,一个会被网络客户端使用;两者都要在真正解释前重新检查。

6 两个虚构组织,就能在不触碰真实公民数据的情况下重演九份案卷

6.1 建一套 fixture,让每个合法动作都有一个只改一项事实的错误双胞胎

深蓝厅和青绿厅从比喻变成真实测试 fixture。应在隔离 Decidim 环境中创建两个组织,使用纯合成名称,不复制任何生产记录;每个组织各有普通参与者和 organization administrator,再加入一名权限更窄的 process administrator,因为问卷与 census 场景正需要这种角色差异。两个 host 即使都解析到本地测试服务器,也要走与部署相同的组织解析中间件。

在深蓝组织创建一个 API 用户,取得一次 API 登录响应,并建立可用于 GraphQL 的普通会话;在两边启用相关 authorization handler,创建 pending 的身份文档与 postal-letter 记录;再准备 demographics 问卷、CSV census 导入和单条记录、私有导出、验证附件、受限参与空间文档、HTML 与静态页面 content block,以及 push subscription map。

标识要便于在证据中关联,却不必故意让应用混淆。把 fixture seed、对象类型、数据库 ID、组织 ID、owner ID、角色、handler 名称和状态写进本地清单。不要强行让两个组织复用主键或 secret,安全测试应在普通数据库行为下成立;只有上游测试明确覆盖的“同 key、不同 secret”场景需要有意复用 API key。

每项案例先跑合法对照:深蓝 API 凭证在深蓝 host 登录,深蓝 JWT 在那里产生深蓝 session;深蓝管理员处理本地 pending authorization,有权管理员编辑 demographics 问题和 census;导出 owner 下载自己的文件,同组织管理员查看验证图片,受限空间成员取得文档;正常 autocomplete、合法富文本和允许的推送 endpoint 也都必须工作。

合法对照通过后,才运行错误双胞胎,并且每次只改一项:保留凭证、切换 host;保留 host、换成另一个组织的记录 ID;保留文件链接、切换 actor;保留 actor、撤销成员资格;保留过滤目标、加入异常文本;保留存储行、改变 renderer 或 sender。单变量变化让失败原因可解释,也避免前面某次拒绝遮住后面遗漏的检查。执行前可以先审核这张紧凑矩阵:

案卷合法对照错误双胞胎必须观察到的结果
45086组织管理员编辑 demographics 问题普通用户或 process admin 尝试同一动作动作被拒,问卷不变
45330本地管理员打开本地 pending 验证本地管理员提交外组织 pending IDdecision command 前返回 not found
45376受支持 autocomplete 返回本地参与者访问旧路由并输入异常语法字符旧 action 不存在,statement 形状稳定且参数化
45377导出 owner 下载精确文件另一登录用户复用链接404,零字节
45378owner 或同组织管理员查看图片外组织用户或非 owner 普通用户复用链接404,零字节
45414key 与 JWT 在签发 host 正常工作同一凭证提交到另一个 host没有 principal、header、token 或 session
45415组织管理员管理 census权限更窄的 process admin 调用每条路由拒绝,行与导入均不变
45572支持的编辑器 markup 正常渲染带活动 tag 或事件属性的存储内容渲染安全内容保留,活动构造消失
45573允许供应商地址抵达 stub历史不受支持端点进入发送任务不受支持目的地永不抵达 client

矩阵必须运行在真正准备部署的 release candidate 上;某个“看起来差不多”的开发分支不能充当发布证据。如果服务同时维护 0.30、0.31 和 0.32,要分别保留 lockfile 和适用预期;每个 skipped cell 都要写出使其不适用的公告范围与包证据,无法解释的 skip 与尚未实现测试没有区别。0.30 fixture 不应硬造那两项不适用案例,而修复后的 0.31.5 与 0.32.0 目标则应覆盖全部九项;结果要按版本线分别保存,不能让最新绿色 build 覆盖旧维护分支的证据。

worker 也是 fixture 的一部分。先用一种进程镜像排队通知,再用计划发布的镜像执行;如果可能出现混合版本,再把顺序反过来,并记录 job serializer、queue 名称、入队时间、领取进程 digest 和重试次数。对于重试任务,要确认每次发送前都重新运行 endpoint validator,第一次筛选结果不能固化在参数里。生成的 private-download link 也要通过 staging 中真实代理和 storage adapter。证明的不只是某个 Ruby 方法孤立通过,而是 host、session、route、storage、job 和 egress 信息在真实交接中都没有丢失。

fixture 必须可销毁、可重复。用代码 seed,每次运行生成新的合成凭证,记录版本与配置,结束后删除数据库和存储对象。不要为了“真实感”导入一张生产身份文档;一块纯色图片或文本文件同样能证明附件授权,却不会额外制造一套敏感公民资料库。

6.2 拒绝是否成立,要看响应之后哪些事情没有发生

HTTP 状态只是第一个观察点,不是最终结论。控制器可能在 command 已改动记录后才返回 404,后台 job 可能在 redirect 前已经入队,代理也可能替换上游响应。每个错误双胞胎都要预先定义禁止后果:没有选出的 principal、没有授权 header、没有 session 用户、没有状态迁移、没有文件字节、没有新增 census 行、没有危险 DOM 节点,也没有 outbound client 调用。

数据库遥测可以区分“查找结构正确”和“最后恰好没查到”。pending-authorization 测试中,要确认查询同时含 organization、handler name、pending state 和 ID;API 认证中,要确认 key lookup 也包含 decidim_organization_id;autocomplete 则应证明退役 controller statement 没有出现,支持搜索的值仍是参数。通常 statement fingerprint 已足够,不要在真实环境日志里记录管理员原始搜索词。

网络测试需要一个位于应用 service 下方的观察点。可以用严格 test double 替换 WebPush.payload_send,或让 staging 出口经过受控代理;历史不支持 endpoint 既不能触发 client 调用,也不能产生 DNS 或代理尝试,合法供应商格式的 fixture 则应恰好产生预期调用。这样才能抓住那些“先让库解析或联网,之后才验证”的错误顺序。

浏览器渲染也要看最后一步:cell 完成后检查实际 DOM 和属性,而不只看 sanitizer helper 返回值。允许的排版、链接与双栏内容必须保留,script element 和事件属性必须消失。如果部署使用自定义 CSP,可以在集成轮次同时启用,但 sanitizer 仍要独立测试,避免一个控制掩盖另一个。

对文件和存储端点,时间本身就是测试维度。生成私有链接后改变 actor 关系,再重放;像升级前一样插入不受支持 subscription,再用升级后的 sender 执行;把旧 content row 恢复到修复 build 并渲染。这些场景证明消费端询问的是当前策略;对象创建日期和当时进程都不能代替今天的授权决定。

这座实验室不能证明外部某套 Decidim 已遭利用或绝对干净。它证明的是一份明确 build 在受控条件下执行了预期决定,并指出生产日志能回答哪些历史问题。错误双胞胎一旦失败,先要给观测分类:fixture 错误、deployment drift、instrumentation 缺失、本地 override 与可复现安全行为,分别对应不同 owner 和下一步测试;改变环境前先封存失败 trace 与状态快照。每份报告都应保留这一区分:验证关闭工程 gate,事件判断仍依赖本地保存的真实证据。

手绘实验室摆着成对的深蓝与青绿议事厅测试台,凭证、记录、文件、页面与推送任务只经过带仪表的检查点。
图 6|每个合法动作都有一个故意做错的双胞胎。仪表同时观察 principal、query、状态变化、文件流、DOM 与出站调用,让拒绝不再只是状态码。

7 代码、运行进程、存储状态与证据一起移动,处置才算真正成功

7.1 修复版本只是第一道发布 gate,不是上线工作的最后一行

  1. 第一道 gate 是 artifact 完整性。只从已复核源码构建一次,解析预期 lockfile,运行单元与集成测试,生成 SBOM 或同类包清单,并按本地实践签名或证明镜像。清单应把源码 commit、lockfile SHA、镜像 digest、编译 asset digest 和部署 manifest 连成一条可追溯链;从 registry 拉回最终镜像再检查,不能只信 CI 工作目录。要在最终构建物中核对 private-download controller、组织范围认证、sanitizer、push validator 和已移除搜索路由;准备拿来构建的 branch 已经改好,仍不等于产物含有这些修复。
  2. 第二道 gate 是进程收敛。滚动发布 Web 与 worker 时,要阻止旧进程在数据库和资产变化后继续接收新任务。新 Web 可以拒绝不支持的 push subscription,旧 worker 却仍可能发送存量记录;新 asset bundle 已经调用 GraphQL,旧 server 仍可能暴露 JSON 路由。readiness probe 应验证实际 build 标识,而不只检查端口能否响应;worker 则要通过心跳、队列消费者清单或启动日志证明旧实例已离开。必要时排空队列、暂停 scheduler,并在发布后逐一记录进程启动时间、加载 digest 和最后领取任务时间。
  3. 第三道 gate 是行为。通过接近生产的 ingress,用合成组织或专用 staging 运行双组织 smoke subset:错 host API key、错 host JWT session、外组织 pending record、无权问卷与 census actor、错用户文件链接、退役搜索路由、历史 cell 清洗和历史 push endpoint 跳过。对应同 host 正常动作也必须成功;把所有请求一律拦掉,不叫修复成功。
  4. 第四道 gate 是可恢复性与证据。按照发布说明,为数据库、应用配置和静态或对象存储制作经过恢复验证的备份,同时明确备份中的敏感导出与验证文档如何受保护;发布窗口前写好 rollback 条件。如果回滚会恢复脆弱代码,就必须搭配临时路由、角色、文件、推送和出口限制,不能让可用性压力一次性重开所有路径。

混合版本窗口内,应主动缩小非必要暴露:旧路由仍可能可达时暂时限制管理用户 picker,延后不关键的 Web Push 投递,不在下载路径不确定时继续生成新私有导出。这些是有明确时限的发布控制,不是固定版本的替代品;负责人、覆盖范围和解除条件都要写入变更记录。

只有当每个进程报告预期 artifact、migration 到达预期状态、合成正常与错误双胞胎都通过、旧路由消失、监控具备后续所需字段时,发布 gate 才能关闭。要从每个负载均衡 backend 和 worker pool 查询 build identity,与 deployment manifest 逐一比较,并把结果和 smoke-test trace 放在一起保存。“包已经升级”是重要里程碑,却还不能证明手里拿着昨天任务的 worker,或藏在遗忘 service 后面的 pod,也已经进入同一个版本故事。

7.2 持久化记录要先保存证据,再按类型复核和处置

代码发布改变的是下一次行为,不会重写此前留下的每一条痕迹。应并行启动 durable-state 工作流,分配单独负责人和访问控制:先保存回答历史问题所需的最少证据,记录哈希与采集时间,再依数据分类和保留规则处置。身份文档、导出、原始 token 或 push key material 不能直接塞进普通事件工单。

私有文件方面,要盘点受影响窗口内创建的 private export、身份验证 attachment 和受限空间 document,确认链接通过哪些邮件、页面、通知或工单发出,storage 或 CDN 日志是否能关联地址、时间、缓存命中与响应。还要区分源站签名地址、CDN 派生地址和浏览器复制后的最终下载 URL,因为它们的撤销与日志位置可能不同。新 controller 会保护以后生成的 private-download route,但此前签发的 Active Storage 地址可能有自己的有效期与缓存行为;是否仍可用,应与存储供应商机制逐项核对,不能靠应用发布推断。

证据保存后,按组织 retention rule 移除过期导出和不再需要的验证文档。如果确实要轮换 link-signing 或 storage secret,先模拟它对 session、其他签名对象、cache、排队邮件和灾备的影响。签名链接出现缺陷,并不等于任何情况下都要仪式性转动 secret;轮换只有在能实质使暴露材料失效、且服务能够承受附带影响时才合理。

Web Push 方面,提取并规范化 endpoint host,同时禁止认证与加密 key 进入宽泛报告;按当前 validator 分类,尽可能定位创建与最后使用时间,再把不受支持记录与 worker、DNS、egress proxy 或防火墙数据关联。可疑行要在删除前保存。陌生 endpoint 既可能是旧浏览器集成、本地供应商或测试数据,也可能是滥用,只有周边证据能区分。

存储 HTML 先枚举受影响 core cell 和本地 override,保留 revision 与管理员审计信息,再用修复 sanitizer 渲染副本并查看变化。政策需要时可以修正源记录中的活动构造,但要保留合法排版。把所有 HTML 一次性剥光,会破坏公开信息,却仍可能漏掉另一个把自定义字段标成 safe 的 renderer。

身份与记录方面,要复核各组织 API key 和 JWT 使用、pending verification 决定、demographics 问题变化、census 导入与单条编辑,以及管理员角色分配,重点寻找路由组织、候选 principal、记录组织、handler 和 actor role 之间的不一致。跨 host 请求不能一概标成恶意,移动客户端、代理、测试和管理员误操作都需要上下文。

SQL 路径应在常规保留策略清除前保存应用、代理与数据库日志。搜索退役路由调用、异常 term 形状、重复延迟模式、数据库错误和 statement fingerprint,再关联已认证管理员与组织;不要把日志里可疑 term 直接对生产重放,确需结构分析时使用隔离实验室,并限制访问,因为搜索词可能含姓名或邮箱片段。

每一类状态有不同结局:文件可能过期,endpoint 被移除,页面经过清洗,key 被撤销,角色被纠正,记录决定被复核,或日志缺口被正式记录为不确定。要写明检查对象总体、筛选查询、保留证据、采取动作、例外、复核人和完成时间;cache、replica、backup、export 和 legal hold 也要进入 disposition,因为删除 primary row 未必清除所有受控副本,有些副本还须依法或依策略封存,不能删除。一个“数据库已清理”勾选框,会恰好抹掉让九份修复可理解的全部差异。

7.3 软件适用、功能可达、观测证据和确认后果,要分别报告四个数字

管理层最常问的是“多少组织受影响”。可辩护的回答,首先要拒绝把四种分母压成一个:软件适用性统计处于公告版本范围的实例或包;功能可达性统计启用相关组件并满足前置条件的组织;观测证据统计符合复核条件的请求、对象或任务;确认后果只统计有独立证据证明发生了未授权读取、修改、执行或出站连接的案例。每个分子都要同时给出分母、统计截止时间、采集方法和已知盲区,否则数字进入后续简报后,很快会被误写成同时描述四类总体。

内部沟通应直接说出正在复核的行为:外组织 principal、范围外 pending record、缺失动作 policy、可复用私有地址、raw 排序表达式、活动存储 markup,或不受支持的出站目的地。每句话还要带上 evidence class、观测窗口、confidence,以及哪项新事实会增强或削弱判断。具体语言帮助服务 owner 找到日志,也帮助用户理解是否需要通知;同时避免两项高危公告把其余中危案例全部膨胀成同一种后果,也避免把单条缺失日志写成已经发生入侵的证据。

四道 gate 最终给运营方留下一份经得起追问的结果:代码已知、运行状态已知、存量数据处置已知,结论也与证据强度相称。它确实比改一个版本字符串慢,却比几个月后重启同一次调查更快——那时没人还能说清哪个 worker 跑过、哪个旧链接活着、原报告中的“受影响”究竟指什么。

手绘公民平台运营室里,复核代码、进程收敛、存量状态和证据报告四道 gate 首尾相连。
图 7|版本、进程、存量状态与证据是四道不同的处置 gate。全部关闭,才能避免已修复 Web 层掩盖旧 worker、历史对象或缺乏依据的结论。

8 真正可持续的经验,要由执行敏感动作的那个函数带到最后

8.1 最后的消费者需要完整句子,可信碎片远远不够

一天结束时,档案员把九份文件送回架上。它们没有合并成一个漏洞,项目也从未给出统一技术标签;但这些修复可以用同一句工程语言记住:即将认证、加载、修改、投递、解释、渲染或联网的函数,必须在当下拿到做出安全决定所需的全部事实。

对 API 登录,这句话是“这枚 key 在当前请求解析出的组织里,选择了这个 API 用户”。只有一枚有效 key,只是半句话;对 GraphQL context,则是“候选用户属于当前 host 选中的同一组织”。一枚签名有效的 JWT 仍只是碎片。两份修复都把组织一致性放进了真正把身份交给下一层的函数。

对 pending verification,完整句子在选出记录前就包含组织、handler、pending 状态和 ID;对问卷与 census,句子在 command 运行前包含 actor、action、policy subject 和 target。主键与已登录管理员单独听起来都很权威,但离开其他成分,谁也说不清允许的究竟是哪次操作。

对私有文件,签名 GlobalID 与 attachment name 证明引用未被篡改,当前 actor 和记录专属 policy 决定今天是否投递;对用户搜索,term 始终留在数据位置,应用掌握查询语法。这里“已经签名”或“已经清洗”都过于模糊,真正有用的问题是:到底证明了什么、在哪里证明、它是不是最终操作真正需要的属性。

对 content cell,管理员写入这一事实不会永久转化为“允许任意浏览器行为”,renderer 只接受编辑器支持的词汇;对 Web Push,以前保存过的 URL 也不会永久转化为“允许建立任意连接”,sender 只接受支持的 HTTPS 供应商 host。时间与异步工作让重复检查成为必要,因为 actor、软件、策略和存储值都可能在写入后变化。

这种思路还会在 code review 中暴露含义过弱的抽象。名叫 find_by_token 的方法可能返回全局用户,而 caller 真正需要组织范围 principal;名叫 download_url 的方法可能编码完整性,却没有 actor policy;html_safe 可能只表示“别转义”;交给 Arel.sql 的字符串则表示“相信这段语法”,即使其中一部分来自请求。

让这些差异在接口中可见:明确传 organization,或从定义清晰的 request context 读取;直接返回 scoped relation,禁止先全局加载记录;把签名引用解析与 authorization 分开;给 policy subject 稳定名称;排序只接受 enum 或应用常量;对富文本返回经过 sanitizer 的类型;在可行时,让出站 client 接受已经校验的 destination object。默认分支应当拒绝未知 subject、attachment name、sort key 和 provider;兼容性不能成为自动放行的理由。调用者若要扩展,必须同时提交 policy、测试与可观测字段。类型和命名不能代替测试,却能让遗漏事实更难藏住,也让 code review 看见安全决定究竟属于哪一层。

这条总结刻意保持克制。它没有宣称 Decidim 每项功能都缺少组织隔离,也没有把九份缺陷拼成一条共同 exploit chain;它只是让九项具体修复保持关联,同时不扭曲各自前置条件和后果。每份案卷仍由自己的来源支撑,完整句子则帮助复核者寻找下一个可能掉落上下文的位置。

8.2 把七月修复写进 closure ledger,让以后每个扩展继续通过

closure ledger 从公开事实开始:九个 CVE、九份公告、三条修复版本线、对应 PR,以及每项来源的复核日期。每份案卷旁边记录本地包范围、可达功能、前置 actor、修复函数、合法对照、错误双胞胎、可观测的禁止后果、存量对象总体、遥测字段、处置 owner 和证据结论。每个结论还应链接到不可变源码 revision、测试运行编号和受控证据位置;后续若修改“适用”或“已确认”等状态,必须留下修改人、依据和复核人。这样,ledger 不只是最终截图,而是一条能够追溯决定如何变化的记录。这比一个笼统“已修复”列有用得多,也足够紧凑,可以在交接后继续阅读。

在可靠位置加入源码检查:组织 controller 中对组织所属模型执行裸 find(params[:id]);不比较组织就把 current_user 送进 API context;把受保护附件渲染为直接 blob URL;在 Active Record 承载语法的方法中插入请求数据;把存储编辑器内容标成 safe;由持久化 URL 直接驱动 outbound client。每个标记都需要人工复核,扫描器没有资格自动判罪。

对会随时间变化的对象,要专门设计问题:退出登录、角色改变、成员资格撤销、对象删除或组织转移后,哪些链接仍应可用?哪些存储 endpoint 每次任务前都要重检?内容应在写入、渲染还是两处清洗?哪些凭证可能被带到另一个 host?把答案写成测试,并在创建与消费之间真正改变状态。

当发布改变路由或集中 service,必须同时证明新路径被采用、旧路径已退役:每个 caller 都使用新接口,每个运行进程都没有旧 route,编译资产不再引用它,本地 fork 也没留下副本。autocomplete 修复正是一个范例:只有替代实现真正消失,收敛才能降低复核成本。

最后一幕故意没有戏剧性:深蓝凭证来到青绿 host,无法形成用户;青绿管理员输入深蓝记录编号,什么也找不到;复制的文件票据不会向错误的人发出字节;异常搜索文字仍是文字,历史 markup 失去活动行为,不受支持的 endpoint 永远到不了网络。各自在自己的厅里,正常动作照常完成。这种安静、可重复的结果,才是九份案卷真正关闭的样子。

手绘公民档案馆里,凭证、记录、文件、查询、页面和网络目的地依次穿过有名称的检查链,最终进入签字 closure ledger。
图 8|长期控制是一句持续传到每个敏感消费者、可以反复测试的话,并写入所有本地扩展都必须继续通过的 closure ledger。

研究记录

9证据、对象与来源

下面保留本文实际使用的标识、时间和原始材料,便于继续调查。

9.1研究对象

报告涉及的产品、行为者、技术、受影响对象和控制点。

CVE 集合CVE-2026-45086; CVE-2026-45330; CVE-2026-45376; CVE-2026-45377; CVE-2026-45378; CVE-2026-45414; CVE-2026-45415; CVE-2026-45572; CVE-2026-45573

2026 年 7 月 13 日公开的九份 Decidim 安全公告

修复版本Decidim 0.30.9; 0.31.5; 0.32.0

适用七月修复的收敛点;仍需按每份公告核对具体包范围

请求上下文decidim.current_organization

组织范围 API 认证读取的 Rack 环境值

组织字段decidim_organization_id

API-key 查找和 GraphQL API 用户上下文中的比较字段

私有下载 purposeprivate_download

签名 GlobalID 与附件名后,再执行实时记录授权

渲染控制decidim_sanitize_editor_admin

修复后的 HTML 与静态页面 content cell 使用的 sanitizer

出口控制PushSubscriptionEndpointValidator

持久化和发送阶段执行的 HTTPS 与允许推送供应商 host 检查

9.2事件时间

  1. 核心修复合并

    复核到的 PR 分别补上动作策略、范围查找、组织身份、私有投递、渲染清洗和端点校验。

  2. 修复版本窗口开启

    Decidim 按安全策略发布 0.30.9、0.31.5 和 0.32.0 修复对象。

  3. 九份公告公开

    项目公开九个 CVE 的独立范围、影响与修复参考。

  4. SOSEC 完成一手资料复核

    发布对象、九份公告与公开实现 diff 完成对照,形成本篇防御报告。

9.3来源与材料

  1. Decidim v0.31.5:七月九份公告与升级步骤https://github.com/decidim/decidim/releases/tag/v0.31.5
  2. Decidim v0.30.9:该分支适用的七项安全修复https://github.com/decidim/decidim/releases/tag/v0.30.9
  3. Decidim v0.32.0:七月修复与后续披露项目的范围区分https://github.com/decidim/decidim/releases/tag/v0.32.0
  4. Decidim 安全策略:受支持版本与披露缓冲期https://github.com/decidim/decidim/security/policy
  5. GHSA-vq6j-hj8w-7v39:问卷问题编辑缺少授权https://github.com/decidim/decidim/security/advisories/GHSA-vq6j-hj8w-7v39
  6. GHSA-86fh-w43w-338c:跨组织访问验证记录https://github.com/decidim/decidim/security/advisories/GHSA-86fh-w43w-338c
  7. GHSA-jvqq-cvh4-xm37:组织用户搜索 SQL 注入https://github.com/decidim/decidim/security/advisories/GHSA-jvqq-cvh4-xm37
  8. GHSA-767h-63j4-5226:可重复使用的私有导出链接https://github.com/decidim/decidim/security/advisories/GHSA-767h-63j4-5226
  9. GHSA-q79h-67vx-m9xg:CSV census 缺少授权https://github.com/decidim/decidim/security/advisories/GHSA-q79h-67vx-m9xg
  10. GHSA-3mvf-82qp-8qh5:可重复使用的验证文档链接https://github.com/decidim/decidim/security/advisories/GHSA-3mvf-82qp-8qh5
  11. GHSA-r3v7-5x4c-c69q:跨组织暴露 JWT/API 用户https://github.com/decidim/decidim/security/advisories/GHSA-r3v7-5x4c-c69q
  12. GHSA-533c-2vh9-4r86:content cell 存储 HTML 执行https://github.com/decidim/decidim/security/advisories/GHSA-533c-2vh9-4r86
  13. GHSA-2g9c-vf8h-prxx:存储型 Web Push endpoint SSRFhttps://github.com/decidim/decidim/security/advisories/GHSA-2g9c-vf8h-prxx
  14. Decidim PR 16665:问卷动作权限与 demographics subjecthttps://github.com/decidim/decidim/pull/16665
  15. Decidim PR 16666:按组织筛选 pending authorizationhttps://github.com/decidim/decidim/pull/16666
  16. Decidim PR 16668:删除 raw 用户搜索并采用 GraphQL autocompletehttps://github.com/decidim/decidim/pull/16668
  17. Decidim PR 16673:API-key 认证绑定当前组织https://github.com/decidim/decidim/pull/16673
  18. Decidim PR 16674:执行 CSV census controller 权限https://github.com/decidim/decidim/pull/16674
  19. Decidim PR 16680:私有下载 controller 与实时记录策略https://github.com/decidim/decidim/pull/16680
  20. Decidim PR 16714:持久化和发送时校验 Web Push endpointhttps://github.com/decidim/decidim/pull/16714
  21. Decidim PR 16756:按当前组织过滤 GraphQL API 用户https://github.com/decidim/decidim/pull/16756
  22. Decidim PR 16451:清洗 HTML 与静态页面 content cellhttps://github.com/decidim/decidim/pull/16451
  23. Decidim PR 16703:执行 CSV census record 新建与更新权限https://github.com/decidim/decidim/pull/16703