读者收获(5 行速览):

  • 受影响对象:@vendure/core < 3.7.3 的多渠道部署——持自身渠道 UpdateOrder 的受限管理员,可向其他渠道订单的 Payment / Refund / Fulfillment / OrderHistoryEntry 发起按裸 ID 的请求;履约状态与库存副作用在所测默认路径未复现(§12.6);
  • 可验证影响(本地隔离实验):跨渠道 Payment/Refund 状态持久化改写、退款命令到达隔离 fake gateway、备注增删、审计记录注入;
  • 根因:子实体非 ChannelAware,渠道归属只在父订单;服务层按 ID 全局加载且不回查父订单渠道,退款路径的渠道检查又被空 lines 与 orders.length 两层真空授权架空;
  • 修复不变量:v3.7.3 新增 assertOrderIsInChannel——任何按 ID 加载子实体的操作,必须在状态变更或网关调用之前断言父订单可见于活跃渠道;
  • 本文独立发现:两层真空授权的叠加、修复者首版补丁再踩 every() 空数组陷阱、默认 ID 策略跨渠道连续、修复版反枚举响应等价(完整清单见下方"本文新增发现")。

一句话结论:Vendure 的渠道(Channel)租户边界对订单子实体(Payment / Refund / Fulfillment / OrderHistoryEntry)来说不是机制,而是逐调用点的约定。这些子实体不携带渠道归属,唯一的边界是父订单;受影响版本里多条 Admin mutation 按 ID 全局加载子实体,从不回查父订单渠道。退款路径最典型:渠道检查寄生在"订单行存在"这个业务事实上,lines: [] 让循环体一次都不执行,随后的"支付与订单不匹配"检查又被 orders.length 短路。两层真空授权叠加,受限管理员就能对另一渠道的支付发起退款——本地实验确认退款命令到达了隔离的 fake gateway;官方公告说明,现实支付配置下此类路径可在另一租户的支付网关上创建实际退款(证据边界见 §9/§13)。

本文新增发现(相对公告与官方补丁材料;逐项见括注章节):

  1. 退款路径的授权失效是两层真空授权的叠加——渠道检查寄生在会被空 lines 跳过的循环体内,"支付-订单不匹配"检查又被 orders.length 短路(§6.1–6.2、§8.2);
  2. 修复者自己的首版 fulfillment 补丁用 Array.every() 判断渠道归属,踩中空数组恒真的同款陷阱,靠两个后续子提交才 fail closed(§8.3);
  3. 默认 AutoIncrementIdStrategy 下 Payment/Refund 的 API ID 为十进制、自增计数跨渠道连续,攻击者可从自身合法数据观察全局 ID 序列形态(§4.3);
  4. 修复版对跨渠道"存在/不存在"ID 返回归一化等价的 ENTITY_NOT_FOUND,反枚举设计经实验验证有效(§11.4);
  5. 无副作用运行时插桩证实修复版"断言先于网关副作用"的事件顺序(§12.2);
  6. 两个阴性边界:所测默认路径上,跨渠道履约状态改写与库存扰动均未复现——未复现 ≠ 无影响(§12.6)。

证据类型约定(全文沿用):

[Official]   官方公告 / 发布记录 / 官方文档
[Source]     本地源码工作树(v3.7.0–v3.7.3,commit 见 §2)
[Commit]     官方修复提交 8a13035a6603ec9a566509d84d8b2fbec9fc1c5e 及其子提交
[Test]       官方回归测试 packages/core/e2e/order-child-channel-scope.e2e-spec.ts(v3.7.3 commit 固定链接见参考资料)
[Experiment] 本仓库 16 个隔离实验(13 个 Docker 动态 + 3 个 CodeQL 静态审计;experiments/,逐项见 §12 与参考资料"本地实验与证据索引")
[Inference]  合理推断(会随文标注依据与不可外推范围)

1. 漏洞概述

项目

内容

证据

编号

GHSA-7qvr-c5vf-xxfh(未关联 CVE,因此全文不出现 NVD/KEV 维度表述)

[Official]

相关公告

本公告与 GHSA-frwg-vjh2-xr74 同属一个修复提交形成的公告族(公告正文自述为 sibling:本公告是其支付侧对应,后者覆盖删除/软删除类操作);官方回归规格头注另注明该规格同时覆盖 GHSA-vqq3-xv95-pmf6 与 GHSA-7xv4-2q6r-84w3 的 Payment/Refund 项。sibling 公告的覆盖面不在本文验证范围——全文统计口径与公告族分界统一见 §4.1 范围表

[Official][Test]

类型

跨渠道不当授权 / IDOR(公告标题:Cross-channel payment/refund IDOR — money movement across tenants)

[Official]

严重性

公告评分卡 CVSS v3.1 9.9、GitHub 严重性标签 Critical(AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H);公告 Severity 文字段落为 9.0。本文按 CVSS v3.1 规范公式复核该向量(算法以 10.0/9.8 两个基准向量校准),计算值为 9.9——评分卡与向量一致,正文 9.0 与向量不符。两处数值均属发布方评估,如实并列

[Official]

影响组件

npm 包 @vendure/core,版本 < 3.7.3

[Official]

修复版本

3.7.3(修复提交 8a13035a)

[Official][Commit]

漏洞模块

Admin API 订单域服务层:payment.service.ts、order.service.ts、fulfillment.service.ts、history.service.ts 中按子实体 ID 直接加载的路径

[Source][Commit]

攻击入口

Admin GraphQL mutation(settlePayment、cancelPayment、transitionPaymentToState、refundOrder、settleRefund、transitionFulfillmentToState、addFulfillmentToOrder、updateOrderNote、deleteOrderNote 等),全部只要求 Permission.UpdateOrder

[Source] order.resolver.ts:75-194

前置条件

① 至少两个渠道(多渠道/多租户/多供应商部署);② 攻击者持有一个受限管理员会话,其角色在自身渠道拥有 UpdateOrder;③ 目标订单的子实体 ID(paymentId / refundId / fulfillmentId / noteId)

[Official][Experiment]

已知影响

公告确认"某些路径可在另一租户的支付网关上创建实际退款;其他路径可改变支付、退款、履约状态或影响库存"。本文逐项检验了其中哪些被本地实验证实(§9、§13)

[Official]

漏洞发生在什么边界上:不在认证边界(攻击者是合法管理员),也不在 GraphQL 权限装饰器(@Allow 如常工作),而在渠道租户边界——多渠道部署中"渠道 B 的管理员只能操作渠道 B 的订单及其子实体"这条安全不变量。破坏点不在入口层,而在服务层按 ID 加载子实体时的对象寻址:加载查询不携带渠道条件,加载之后也不验证父订单渠道。于是"权限检查所针对的对象(渠道 B 的会话)"与"实际操作的对象(渠道 A 的 Payment)"是两个不同的东西。

时间线(可确认部分):

图 1:GHSA-7qvr-c5vf-xxfh 时间线(可确认部分)。虚线框表示时点不可确认;各节点证据标签与正文一致,未确认项如实标注而非推断

2. 研究环境与证据基线

版本固定(source-repositories/vendurehq--vendure/worktrees/,四版本 detached 工作树;点击 commit 直达 GitHub 固定版本):

标签

commit

v3.7.0

a2b8ff8b33b1

v3.7.1

d968e114f32a

v3.7.2(受影响代表版)

fdbad19fe51e

v3.7.3(修复版)

2cc309deb539

文中 [Source] 的 file:line 均相对 packages/core/src/,除特别标注外指 v3.7.2;主要源码摘录处附 GitHub 固定 commit 直达链接,读者可逐函数在线核对。

动态实验基线(13 个动态实验共用,见 experiments/*/README.md):本地隔离 Docker Compose;官方两渠道 e2e 夹具(SQLite,升级实验为 PostgreSQL 16);运行时禁网、只读 rootfs、非 root、tmpfs 数据、cap_drop: ALL、不发布端口;支付处理器为进程内 spy 或独立 fake gateway——无真实网关、凭据、资金操作。运行时统一:13 个动态实验(参考资料表中除三个静态审计实验外的全部目录)全部以官方 node:22-bookworm-slim 镜像为容器基础,无一例外;Bun 1.2.21 装在该 Node 22 镜像内,只负责依赖安装与官方 e2e 套件的构建/运行(bun install --frozen-lockfile + bunx lerna run ci),不是容器运行时;基础镜像 digest 记录于各 results/<UTC>/metadata.txt。另外 3 个静态审计实验(§11.5、§14.3)用本机 CodeQL CLI 对固定源码工作树运行,不使用容器。

判定规则:每个实验同时设正对照(同渠道 / 授权管理员操作必须成功)与负对照(修复版跨渠道必须拒绝);构建失败或正对照失败记为"不确定",不计入结论。16 个实验中 14 个"实验确认/通过",2 个履约实验"未复现"(阴性边界,§12.6)。

不可外推边界(全文适用):所有动态结论限于已测版本(v3.7.0–v3.7.3)、官方 SQLite/PostgreSQL 夹具、默认支付/退款/履约/库存流程、默认 ID 策略;fake gateway 的记录不是资金结果;静态审计结论限于声明的语法范围(§11.5)。

3. 渠道模型:这条边界是什么、由谁执行

要看懂这个漏洞,得先看 Vendure 的渠道隔离实际是怎么实现的。三层事实,每一层都直接参与漏洞成因。

3.1 数据层:渠道过滤是显式选择加入,不是默认行为 [Source]

ChannelAware 是一个标记接口:实现了它的实体(如 Customer、Product)拥有 channels 多对多关系。渠道作用域加载必须由调用点显式选择,TransactionalConnection(connection/transactional-connection.ts:306-378)只提供三种选择加入方式:

// transactional-connection.ts(v3.7.2 :306-343,v3.7.3 结构相同)—— 摘录,注释为本文所加
private async getEntityOrThrowInternal<T extends VendureEntity>(
    ctx: RequestContext,
    entityType: Type<T>,
    id: ID,
    options: GetEntityOrThrowOptions = {},
): Promise<T> {
    let entity: T | undefined;
    if (options.channelId != null) {
        // 路径 A:调用方显式传入 channelId → 走 findOneInChannel(join channels 表过滤)
        const { channelId, ...optionsWithoutChannelId } = options;
        entity = await this.findOneInChannel(
            ctx, entityType as Type<T & ChannelAware>, id, options.channelId, optionsWithoutChannelId,
        );
    } else {
        // 路径 B:未传 channelId → 普通 findOne,按主键全局加载,无任何渠道条件
        const optionsWithId = { ...options, where: { ...(options.where || {}), id } };
        entity = await this.getRepository(ctx, entityType)
            .findOne(optionsWithId)
            .then(result => result ?? undefined);
    }
    if (!entity || /* 软删除检查 */ ...) {
        throw new EntityNotFoundError(entityType.name, id);   // 加载不到才报错;加载到不问渠道
    }
    return entity;
}

// 路径 A 的作用域实现(同文件 :350-378)——渠道条件只存在于这类显式选择加入的方法里
findOneInChannel<T extends ChannelAware & VendureEntity>(
    ctx: RequestContext, entity: Type<T>, id: ID, channelId: ID, options: FindOneOptions<T> = {},
) {
    const qb = this.getRepository(ctx, entity).createQueryBuilder('entity');
    ...
    qb.leftJoin('entity.channels', '__channel')
        .andWhere('entity.id = :id', { id })
        .andWhere('__channel.id = :channelId', { channelId });  // 渠道过滤在这里,且仅在这里
    return qb.getOne().then(result => result ?? undefined);
}

关键结论:getRepository() 返回的是普通事务绑定仓库,数据层不存在自动渠道过滤。getEntityOrThrow(Entity, id) 不带 channelId 时就是一条按主键的全局查询——这是设计好的默认行为,不是实现疏漏。渠道边界守不守得住,取决于每个调用点是否"记得"选择路径 A 或自行 join 渠道表。

3.2 请求侧:权限只回答"你在这个渠道能不能做这件事" [Source]

Admin API 的授权由两层组成:

  1. 渠道归属:请求携带渠道令牌(默认 HTTP 头 vendure-token,config/vendure-config.ts:182),决定 RequestContext.channelId;
  2. 角色权限:@Allow(Permission.UpdateOrder) 装饰器最终落到 RequestContext.userHasPermissions()(api/common/request-context.ts:268-278):
userHasPermissions(permissions: Permission[]): boolean {
    const user = this.session?.user;
    if (!user || !this.channelId) return false;
    // 只查找"活跃渠道"上的权限集合
    const permissionsOnChannel = user.channelPermissions.find(c => idsAreEqual(c.id, this.channelId));
    if (permissionsOnChannel) {
        return this.arraysIntersect(permissionsOnChannel.permissions, permissions);
    }
    return false;
}

user.channelPermissions 来自 User.roles → Role.channels(entity/role/role.entity.ts:30,角色可对应多个渠道)。也就是说,授权层的全部语义是:"这个会话在渠道 B 上是否持有 UpdateOrder"——它不接收、也不检查任何目标实体参数。这不是缺陷,是授权层职责的正常边界;但它意味着"目标对象是否属于渠道 B"必须由资源层另行回答。漏洞正是资源层没有回答这个问题。

(表述纪律:Administrator 实体没有单一渠道归属,权限渠道由 User.roles → Role.channels 决定且可为多个。本文避免使用"管理员所属渠道"这一不成立的概念,检测规则同样如此,见 §13。)

3.3 资源层:子实体的渠道归属住在父订单里 [Source][Commit]

本案四个目标实体——Payment、Refund、Fulfillment、OrderHistoryEntry(以及作为寻址中介的 OrderLine)——都不是 ChannelAware。实体声明直接呈现了这条租户边界的归属结构(payment.entity.ts:21 / fulfillment.entity.ts:19 / order.entity.ts:44):

// entity/payment/payment.entity.ts:21 —— 子实体:仅继承 VendureEntity,无 channels 关系
export class Payment extends VendureEntity implements HasCustomFields {
    ...
}

// entity/fulfillment/fulfillment.entity.ts:19 —— 同构;Refund 与 OrderHistoryEntry(继承 HistoryEntry 基类)亦无 channels
export class Fulfillment extends VendureEntity implements HasCustomFields { ... }

// entity/order/order.entity.ts:44,149-152 —— 对照:父对象 Order 才实现 ChannelAware
export class Order extends VendureEntity implements ChannelAware, HasCustomFields {
    ...
    @ManyToMany(type => Channel)
    @JoinTable()
    channels: Channel[];
    ...
}

图 2:渠道归属的数据模型。Channel 与 Order 多对多;四个订单子实体与寻址中介 OrderLine 均非 ChannelAware——渠道归属只住在父订单,实体声明行号与上文引文一致

修复提交的说明把这个模型写得很直白 [Commit]:

Payment, Refund, Fulfillment and OrderHistoryEntry are not ChannelAware, so the parent Order is the only Channel boundary they have. Any operation which looks one of them up by id must check that the parent Order is visible in the active Channel before it mutates state or calls out to a payment gateway, otherwise a Channel-scoped administrator can act on another Channel's data.

两个细节值得停下来看。其一,Order.channels 是多对多——一个订单可以同时属于多个渠道(卖方订单/seller order 场景),所以渠道判断是"父订单的渠道集合是否包含活跃渠道",而不是等值比较。其二,"before it mutates state or calls out to a payment gateway"——网关调用不可回滚,断言必须落在副作用之前。这一点后来被运行时插桩实验直接证实(§12.2)。

3.4 安全不变量

把上面的模型压缩成一条不变量:

不变量 I:对任意 Admin mutation M(ctx, childEntityId),
若 ctx 会话的权限仅在渠道集合 S 内有效,
则 M 实际操作的 childEntity 的父 Order.channels ∩ S 必须非空(至少包含活跃渠道)。

v3.7.2 的实现里,不变量 I 的执行完全依赖各服务方法"记得"做父订单校验。下文逐函数展示:有的路径记得一半(渠道检查写在了会被空输入跳过的位置),有的路径完全不记得。

4. 攻击面与输入控制

4.1 入口清单与操作面

官方回归规格共 25 个用例,头注明确写着公告族归属:"Covers GHSA-7qvr-c5vf-xxfh, GHSA-vqq3-xv95-pmf6 and the Payment/Refund items of GHSA-7xv4-2q6r-84w3"(order-child-channel-scope.e2e-spec.ts:133-134)[Test]。官方只对个别用例标注归属(第 389 行标注本公告、第 595 行标注 GHSA-vqq3-xv95-pmf6),没有给出 25 用例的逐公告完整分界。本文范围表如下(全文所有"11/11""四类 11 项"均指此表口径;sibling 公告的覆盖面不在本文验证范围,后文不再逐处重述):

规格用例组

计入本文 11 项分母?

归属依据

四类操作面跨渠道动作:支付 ×4、退款 ×3、履约 ×2、订单备注 ×2

计入(下表)

与公告表述逐项对应;规格第 389 行标注本公告 [Official][Test]

客户备注面用例

不计入

父对象为 Customer,非本公告的订单子实体面

类型过滤 / 任意类型删除用例(第 597/607 行)

不计入(作邻近维度呈现,§6.6)

规格第 594-595 行注释归于 GHSA-vqq3-xv95-pmf6 [Test]

订单"不可读 / 状态不变"断言、正向对照用例

不计入

支撑性断言与对照,非独立操作面

"四类 11 项"是本文/本仓库对公告与规格材料的归纳口径,不是官方逐项清单 [Test][Experiment channel-scope-matrix]:

操作面

mutation

提交的子实体 ID

v3.7.2 行为(实验观察)

支付 ×4

settlePayment / cancelPayment / transitionPaymentToState(Cancelled) / transitionPaymentToState(Error)

paymentId

均未被渠道拒绝,前三者直达网关 spy

退款 ×3

refundOrder(lines: []) / refundOrder(lines: [渠道 A 行]) / settleRefund

paymentId / orderLineId / refundId

空 lines 退款到达 createRefund 处理器;settleRefund 将目标 refund 结算为 Settled

履约 ×2

transitionFulfillmentToState / addFulfillmentToOrder

fulfillmentId / orderLineId

入口可达(报错点与修复版的渠道拒绝不同)

备注 ×2

updateOrderNote / deleteOrderNote

noteId(OrderHistoryEntry ID)

更新成功;删除实际返回 DELETED

4.2 输入可控性

输入

可控性

说明

paymentId / refundId / fulfillmentId / noteId / orderLineId

完全可控

GraphQL 参数字符串,经 ID 编码策略解码为主键后直接进入 findOne({where: {id}}) 全局查询

lines、shipping、amount、state、transactionId、note 内容

完全可控

决定走哪条分支、退多少钱、迁移到哪个状态

渠道令牌(vendure-token 头)

攻击者设为自己的渠道 B

决定 ctx.channelId,即权限判定与(修复后的)断言所对照的渠道

目标实体的初始状态

不可控,但可预期

需目标订单已处于业务合法态(如已支付的 Authorized payment、Pending refund),否则状态机本身会拒绝——这不是安全防线,只是业务前置

前置依赖:需要认证(管理员会话)+ 特定权限(自身渠道 UpdateOrder)+ 特定部署拓扑(≥2 渠道)+ 目标子实体 ID。不依赖并发、不依赖用户交互、不依赖特定插件。单渠道部署没有"另一个渠道",不暴露此面([Inference],公告口径)。

4.3 链条最前端:目标 ID 从哪来(实验 id-observability)

跨渠道操作要求攻击者提交渠道 A 的 ID,此前这只是隐含假设。实验在显式采用生产默认 AutoIncrementIdStrategy 的两渠道夹具中观察到 [Experiment,results/20260919T045603Z/]:

  • Admin API 返回的 Payment/Refund ID 为原始十进制字符串(AutoIncrementIdStrategy.encodeId() 返回 primaryKey.toString(),[Source] auto-increment-id-strategy.ts);
  • 渠道 A 的 Payment ID 为 1,渠道 B 管理员从自己的订单读到的 Payment ID 为 3——同类型实体的自增计数跨渠道连续、不按渠道重置;
  • 同时渠道 A 订单在该会话中不可见(order: null),排除了"直接读目标订单"的路径。

结论:默认部署下,攻击者可从自身合法数据观察到全局连续的 ID 序列形态。边界:这不证明仅凭自身 ID 就必然命中任意未知目标(未测 ID 搜索空间、自定义 ID 策略的部署比例);枚举面的另一半证据见 §11.4 的反枚举响应等价实验。

5. 完整调用链

以最典型的两条路径为例,从 GraphQL 入口走到敏感操作。全部入口 resolver 都是同一形态,以退款入口为例(v3.7.2 order.resolver.ts:115-123):

@Transaction()
@Mutation()
@Allow(Permission.UpdateOrder)          // 唯一的权限语义:会话在活跃渠道上持有 UpdateOrder
async refundOrder(
    @Ctx() ctx: RequestContext,
    @Args() args: MutationRefundOrderArgs,
): Promise<ErrorResultUnion<RefundOrderResult, Refund>> {
    return this.orderService.refundOrder(ctx, args.input);   // 裸 ID 原样下传服务层
}

5.1 退款链(本次漏洞的"资金出口"路径)

图 3:空 lines 跨渠道退款链(v3.7.2)。七道检查逐一放行,两层真空授权(②⑤,红框)叠加后直达网关副作用(⑦);节点标注 v3.7.2 源码行号,逐行分析见 §6.1–6.2

5.2 支付链(无任何渠道相关检查的路径)

图 4:跨渠道支付链(v3.7.2)。order 关系加载了却从不比对渠道,从加载到网关调用之间没有任何渠道相关逻辑

cancelPayment(:200)与 transitionToState 的 fallthrough 分支(:76-95,经 findOneOrThrow,:62)同构。

5.3 其余路径

  • settleRefund:order.service.ts:1971-1994,事务内 getEntityOrThrow(Refund, {relations: ['payment','payment.order']}) 全局加载 → refundStateMachine.transition 到 Settled → save → finalize()(写审计 history)→ 发事件;
  • transitionFulfillmentToState:fulfillment.service.ts:154-205,全局加载 Fulfillment 后按 line.id IN (...) 反查订单——查询构造器手工 join 了 order.lines 却没有 join order.channels(v3.7.2 :178-183);
  • 订单/客户备注:history.service.ts:346/369/376/396,getEntityOrThrow(HistoryEntry, id) 全局加载后直接改/删。

共同形态:每条链的"检查对象"是会话权限(渠道 B),"使用对象"是全局加载出的渠道 A 子实体,中间没有任何一处把两者关联起来。

6. 关键源码逐函数分析

6.1 getOrdersFromLines:寄生在循环体里的渠道检查 [Source](order-utils.ts:140-166)

// v3.7.2 service/helpers/utils/order-utils.ts:140-166(完整函数体,注释为本文所加)
export async function getOrdersFromLines(
    ctx: RequestContext,
    connection: TransactionalConnection,
    orderLinesInput: OrderLineInput[],
): Promise<Order[]> {
    const orders = new Map<ID, Order>();
    const lines = await connection.getRepository(ctx, OrderLine).find({
        where: { id: In(orderLinesInput.map(l => l.orderLineId)) },   // ①
        relations: ['order', 'order.channels'],                        // ②
        order: { id: 'ASC' },
    });
    for (const line of lines) {                                       // ③
        const inputLine = orderLinesInput.find(l => idsAreEqual(l.orderLineId, line.id));
        if (!inputLine) {
            continue;
        }
        const order = line.order;
        if (!order.channels.some(channel => channel.id === ctx.channelId)) {   // ④ 渠道检查
            throw new EntityNotFoundError('Order', order.id);
        }
        if (!orders.has(order.id)) {
            orders.set(order.id, order);                               // ⑤ 收集去重后的订单
        }
    }
    return Array.from(orders.values());
}

这个函数的意图显然是安全相关的:②处特意加载了 order.channels,④处逐单比对活跃渠道。问题出在检查的位置——它写在 ③ 的迭代体内,执行前提是"存在被加载的订单行"。当 orderLinesInput 为空数组时:

  • ①处 In([]) 匹配不到任何行,lines 为空;
  • ③的循环体一次都不执行,④的渠道检查随之蒸发;
  • 函数正常返回 []——不是报错,是一个语义上"合法"的空集合。

这就是"授权谓词寄生在业务数据存在性上"的形态:数据为空 ⇒ 谓词未执行 ⇒ 调用方拿到一个看起来正常的返回值。④还附带一个信息泄露面:它抛错时点名的是 Order 与订单 ID——等于向跨渠道调用者确认"该订单存在于其他渠道"。这一点补丁也改了(§11.4)。

还有一点容易忽略:lines: [] 不是什么怪异输入。修复版注释写得很明白 [Commit]:

An empty lines array is a legitimate way to refund shipping or an arbitrary amount.

空行数组是退款运费/任意金额的合法业务用法——触发条件落在一条完全正常的业务路径上,攻击者不需要构造畸形输入,一个 shipping: 100 字段就够了。

6.2 OrderService.refundOrder:两层真空授权的汇聚点 [Source](order.service.ts:1929-1967)

// v3.7.2 order.service.ts:1929-1967(完整函数体,注释为本文所加)
async refundOrder(
    ctx: RequestContext,
    input: RefundOrderInput,
): Promise<ErrorResultUnion<RefundOrderResult, Refund>> {
    if (
        (!input.lines || input.lines.length === 0 || summate(input.lines, 'quantity') === 0)
        && input.shipping === 0 && !input.amount
    ) {
        return new NothingToRefundError();                           // A
    }
    const orders = await getOrdersFromLines(ctx, this.connection, input.lines ?? []);  // B
    if (1 < orders.length) {
        return new MultipleOrderError();
    }
    const payment = await this.connection.getEntityOrThrow(ctx, Payment, input.paymentId, {
        relations: ['order'],                                         // C
    });
    if (orders && orders.length && !idsAreEqual(payment.order.id, orders[0].id)) {
        return new PaymentOrderMismatchError();                      // D
    }
    const order = payment.order;
    if (order.state === 'AddingItems' || order.state === 'ArrangingPayment'
        || order.state === 'PaymentAuthorized') {
        return new RefundOrderStateError({ orderState: order.state }); // E
    }
    const createdRefund = await this.paymentService.createRefund(ctx, input, order, payment); // F
    if (createdRefund instanceof Refund) {
        await this.eventBus.publish(new RefundEvent(ctx, order, createdRefund, 'created'));
    }
    return createdRefund;
}

逐个检查为什么拦不住渠道 B 管理员针对渠道 A payment 的请求:

  • A(NothingToRefund):纯金额校验。攻击者给 shipping: 100(或 amount),直接通过。它的存在反而说明开发者考虑过"无订单行退款"的输入形态。
  • B(getOrdersFromLines):如 §6.1,空 lines 返回 [],渠道检查未执行。第一层真空。官方回归规格的注释把这一点写成了对漏洞的定性 [Test]:

GHSA-7qvr-c5vf-xxfh: with an empty lines array the PaymentOrderMismatchError guard is skipped, so the Payment lookup was the only thing standing between the caller and a real refund against another Channel's payment gateway.

  • C(getEntityOrThrow):Payment 不是 ChannelAware,channelId 选项也无从谈起;这条查询按主键全局加载渠道 A 的 Payment,成功返回。攻击者的 paymentId 从此处开始变成一个跨渠道的对象引用。
  • D(PaymentOrderMismatch):设计上防"退款行与支付不属于同一订单"的业务一致性检查。orders.length && 在 orders === [] 时短路——第二层真空。这个检查的本职是防业务错配,不是防越权;但在正常路径上它客观上"挡住"过跨渠道带行退款(行属于渠道 B、payment 属于渠道 A → mismatch),空 lines 让这层巧合保护也消失了。
  • E(订单状态白名单):检查的是目标订单的业务状态(活跃中不允许退款)。渠道 A 那笔已支付订单恰恰处于可退款状态——业务状态机对"谁在退"没有任何发言权。
  • F(createRefund):携带渠道 A 的 payment(含网关交易标识)进入支付处理器,PaymentMethodHandler.createRefund() 被调用——网关命令在此生成并出站(§12.3 的 fake gateway 实验观测到了它的精确内容)。

为什么这个输入能导致这种内部状态:lines: [] + shipping: 100 让 B 处返回空集合,空集合同时瓦解了 B 自身的渠道检查与 D 处的短路条件;C 处的全局加载对任何主键一视同仁;E 处的状态检查站在业务视角而非授权视角。四个检查各自"正确地"通过了,汇成一个授权上完全错误的结果。

6.3 getEntityOrThrowInternal:全局加载是默认,不是例外 [Source]

§3.1 已展示其分支结构。要点在于:不带 channelId 的 getEntityOrThrow 是 Vendure 服务层使用最普遍的加载原语,遍布全部服务;它对 ChannelAware 实体同样不自动过滤(channelId 是调用方要显式传的 option)。静态盘点给出了量化 [Experiment channel-boundary-callsite-audit]:仅 service/services 六个目标实体的直接 getEntityOrThrow 加载点,v3.7.2 就有 17 个,全部既无 channelId 选项、也无同函数父订单断言(v3.7.3 为 16 个,其中 6 个获得断言,见 §11.5)。全局加载本身不是 bug——Customer 这类 ChannelAware 实体的多数加载点同样全局加载,但那些路径往往经由已按渠道过滤的父对象到达。危险的是"从攻击者直接提供的 ID 到全局加载"且"实体非 ChannelAware"且"加载后进入状态变更/网关调用"的组合。

6.4 PaymentService 三个入口:连寄生的检查都没有 [Source]

三个入口的分派关系在 transitionToState(v3.7.2 payment.service.ts:76-95)里一目了然——Settled/Cancelled 两个目标状态分别委派给 settlePayment/cancelPayment,其余状态走 fallthrough:

// v3.7.2 payment.service.ts:76-95(transitionToState,摘录)
async transitionToState(ctx: RequestContext, paymentId: ID, state: PaymentState) {
    if (state === 'Settled') {
        return this.settlePayment(ctx, paymentId);
    }
    if (state === 'Cancelled') {
        return this.cancelPayment(ctx, paymentId);
    }
    const payment = await this.findOneOrThrow(ctx, paymentId);   // ← 全局加载(见 :62-67),无渠道条件
    const fromState = payment.state;
    return this.transitionStateAndSave(ctx, payment, fromState, state);
}

settlePayment(:173-198)是这三条链的代表性形态,从加载到网关调用之间没有任何渠道相关逻辑:

// v3.7.2 payment.service.ts:173-198(完整函数体,注释为本文所加)
async settlePayment(ctx: RequestContext, paymentId: ID): Promise<PaymentStateTransitionError | Payment> {
    const payment = await this.connection.getEntityOrThrow(ctx, Payment, paymentId, {
        relations: ['order'],                       // ★父订单加载了……
    });
    const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(
        ctx, payment.method,                        // 按支付方式名解析 handler(不问渠道)
    );
    const settlePaymentResult = await handler.settlePayment(   // ★……却从不比对渠道,直接进入网关调用
        ctx, payment.order, payment, paymentMethod.handler.args, paymentMethod,
    );
    const fromState = payment.state;
    let toState: PaymentState;
    payment.metadata = this.mergePaymentMetadata(payment.metadata, settlePaymentResult.metadata);
    if (settlePaymentResult.success) {
        toState = 'Settled';
    } else {
        toState = settlePaymentResult.state || 'Error';
        payment.errorMessage = settlePaymentResult.errorMessage;
    }
    return this.transitionStateAndSave(ctx, payment, fromState, toState);   // 状态持久化
}

cancelPayment(:200-227)与 settlePayment 同构,仅 handler 方法与目标状态不同。这条路径上不存在任何"寄生"于业务数据的检查——它甚至没有可被空输入绕过的检查。矩阵实验的 4 个支付面断言在 v3.7.2 全部失守、跨渠道 Authorized → Cancelled 实际落库(§12.4),与源码形态完全一致。

6.5 settleRefund 与 transitionFulfillmentToState [Source]

settleRefund(order.service.ts:1971-1994)的完整事务体如下——全局加载 Refund 后一路走到状态落库与审计写入,中间没有渠道检查:

// v3.7.2 order.service.ts:1971-1994(完整函数体,注释为本文所加)
async settleRefund(ctx: RequestContext, input: SettleRefundInput): Promise<Refund> {
    return this.connection.withTransaction(ctx, async txCtx => {
        const refund = await this.connection.getEntityOrThrow(txCtx, Refund, input.id, {
            relations: ['payment', 'payment.order'],       // ★全局加载,链到父订单但不断言渠道
        });
        refund.transactionId = input.transactionId;        // ★交易标识直接取自攻击者输入
        const fromState = refund.state;
        const toState = 'Settled';
        const { finalize } = await this.refundStateMachine.transition(
            txCtx, refund.payment.order, refund, toState,  // 状态机迁移到 Settled
        );
        await this.connection.getRepository(txCtx, Refund).save(refund);   // 状态落库
        await finalize();                                  // onTransitionEnd 钩子:写审计 history
        await this.eventBus.publish(
            new RefundStateTransitionEvent(fromState, toState, txCtx, refund, refund.payment.order),
        );
        return refund;
    });
}

跨渠道结算实验观察到目标 refund 从 Pending 读回为 Settled、且受害订单新增归因给渠道 B 管理员的 ORDER_REFUND_TRANSITION(§12.4)。

transitionFulfillmentToState(fulfillment.service.ts:154-205)的订单反查是本组里最"接近正确"的实现——它至少想到了"从 fulfillment 找到 owning orders"再交给状态机。关键片段在查询构造器:

// v3.7.2 fulfillment.service.ts:177-183 及 :187(摘录,注释为本文所加)
const orderLinesIds = unique(fulfillment.lines.map(lines => lines.orderLineId));
const orders = await this.connection
    .getRepository(txCtx, Order)
    .createQueryBuilder('order')
    .leftJoinAndSelect('order.lines', 'line')       // ★手工 join 了 order.lines 反查拥有订单……
    .where('line.id IN (:...lineIds)', { lineIds: orderLinesIds })
    .getMany();                                     // ★……却没 join order.channels,也没任何渠道比对
// 随后 orders 仅作为状态机上下文传入:
const result = await this.fulfillmentStateMachine.transition(txCtx, fulfillment, orders, state);

加载到的 orders 只用于状态机上下文,不用于授权。

6.6 HistoryService:备注与"类型混淆删除" [Source][Commit]

v3.7.2 的订单历史改/删入口(history.service.ts:346-374)都是 getEntityOrThrow(OrderHistoryEntry, id) 全局加载后直接改/删。两个方法并排看,类型过滤的不对称一目了然:

// v3.7.2 history.service.ts:346-368(updateOrderHistoryEntry,摘录,注释为本文所加)
async updateOrderHistoryEntry<T extends keyof OrderHistoryEntryData>(ctx, args) {
    const entry = await this.connection.getEntityOrThrow(ctx, OrderHistoryEntry, args.entryId, {
        where: { type: args.type },              // 更新:有类型条件(仍无渠道条件)
    });
    if (args.data) { entry.data = args.data; }
    if (typeof args.isPublic === 'boolean') { entry.isPublic = args.isPublic; }
    const administrator = await this.getAdministratorFromContext(ctx);
    if (administrator) { entry.administrator = administrator; }
    const newEntry = await this.connection.getRepository(ctx, OrderHistoryEntry).save(entry);
    await this.eventBus.publish(new HistoryEntryEvent(ctx, entry, 'updated', 'order', args));
    return newEntry;
}

// v3.7.2 history.service.ts:369-374(deleteOrderHistoryEntry,完整函数体)
async deleteOrderHistoryEntry(ctx: RequestContext, id: ID): Promise<void> {
    const entry = await this.connection.getEntityOrThrow(ctx, OrderHistoryEntry, id);  // ★无 where、无类型过滤
    const deletedEntry = new OrderHistoryEntry(entry);
    await this.connection.getRepository(ctx, OrderHistoryEntry).remove(entry);        // ★直接删除
    await this.eventBus.publish(new HistoryEntryEvent(ctx, deletedEntry, 'deleted', 'order', id));
}

客户备注路径(:376/:396)同构——尽管 Customer 是 ChannelAware(可用 findOneInChannel),父对象同样未被校验。补丁除了加父对象断言,还给 deleteOrderHistoryEntry/deleteCustomerHistoryEntry 增加了 options.type 过滤,且 deleteOrderNote/deleteCustomerNote 固定传 ORDER_NOTE/CUSTOMER_NOTE 类型 [Commit]。原因:v3.7.2 的"删除订单备注"没有类型过滤,可以删除任意类型的历史记录——包括状态迁移记录等非备注条目(官方规格第 597/607 行用例专测此点 [Test])。归属上,跨渠道删除备注本身计入本文 11 项分母(§4.1 备注 ×2);"可删任意类型"这一维度官方标注给 sibling 公告(见 §4.1 范围表),本文将其作为同一越权原语上的邻近维度一并呈现,不计入 11 项。

7. 数据流、控制流与状态流

7.1 核心数据流(以空 lines 跨渠道退款为例)

真正跨信任边界流动的数据只有四组:

  • 渠道令牌:攻击者请求头 → ctx.channelId,权限判定与修复后断言的共同基准;
  • mutation 输入:lines 的空集合格式决定渠道检查是否执行,paymentId 定位渠道 A 的 Payment,shipping 成为退款金额;
  • 全局加载出的 payment 及其 .order:v3.7.2 中从加载到进入网关调用,全程未与 ctx.channelId 比对;
  • 返回的 createdRefund:跨渠道退款凭证。

逐变量、逐检查点的位置见 §5.1 与 §6.2,不再重复。这里只补一条数据形态事实:GraphQL ID 字符串经 EntityIdStrategy 解码为主键后直接进入全局 findOne——默认 AutoIncrementIdStrategy 下就是十进制主键本身(§4.3),不存在任何按渠道的命名空间隔离。

7.2 控制流:本应拦截的检查及其失效方式

检查

层级

失效方式

@Allow(UpdateOrder)

授权层

未失效,但语义不覆盖:只校验活跃渠道上的会话权限,与目标实体无关

getOrdersFromLines 渠道检查

资源层

未获执行(空集合跳过循环体)

PaymentOrderMismatchError

业务一致性层

短路(orders.length && 且 orders = [])

RefundOrderStateError 状态白名单

状态机层

语义不覆盖:检查目标订单业务状态,不检查操作者渠道

状态机迁移规则

状态机层

合法通过:Authorized → Cancelled、Pending → Settled、Created → Pending 本身都是合法迁移

注意最后一个:状态机是本组检查里唯一"没有失效"的——它忠实执行了合法迁移。这提示一个审计视角:状态机无法区分"谁"触发了合法迁移,把状态机当授权边界是这一类系统的常见错觉。

7.3 状态流

图 5:正常路径与跨渠道路径的状态流对照。跨渠道一侧的三个副作用均为 v3.7.2 本地实验实测(§12.3–12.4),破坏在受害方一侧留下可观测痕迹

8. 漏洞类型专项分析:授权边界 + 真空授权

8.1 检查对象 ≠ 使用对象

本案是"检查与使用分离"的经典结构,但与常见 IDOR 不同:分离点不在"检查后重新解析输入",而在检查的对象根本不是使用对象:

图 6:检查对象 ≠ 使用对象。授权层校验的是会话与其活跃渠道的关系,资源层按裸 ID 全局寻址;中间缺失的谓词正是 v3.7.3 新增 assertOrderIsInChannel 所回答的问题(§11.1)

8.2 真空授权(vacuous authorization):空集合上的谓词

退款路径的独有深度在于它的两层结构 [Source] [Experiment vacuous-authorization-audit]:

第一层:渠道检查写在 for (const line of lines) 循环体内
        lines = [] ⇒ 循环体零次执行 ⇒ 检查未获执行(不是"返回真",是"从未运行")
第二层:PaymentOrderMismatch 前置 orders.length &&
        orders = [] ⇒ 短路 ⇒ 不匹配检查未获执行

术语上要分清:这两层都是"检查未获执行",不是某个条件"恒真"。"空集合上恒真"只适用于 Array.every()。这个区分不是抠字眼——下一节就会看到,修复者自己踩的正是 every() 这个坑:

8.3 修复者自己的同款失误 [Commit]

修复提交的子提交序列里藏着一条很有研究价值的线索:首版 fulfillment 补丁用 orders.every(...) 判断渠道归属——Array.every 在空数组上恒真,一个"没有拥有订单的 fulfillment"会通过渠道测试。靠两个后续补救子提交才堵上:

  • "Fail closed on fulfillment transitions with no owning order"
  • "Explain the empty-array behaviour of Array.every"

最终版代码(v3.7.3 fulfillment.service.ts:193-218,transitionToState 事务体内)把教训写进了注释,并把检查放在状态机调用之前 fail closed:

// v3.7.3 fulfillment.service.ts:193-218(摘录,注释为本文所加)
const orders = await this.connection
    .getRepository(txCtx, Order)
    .createQueryBuilder('order')
    .leftJoinAndSelect('order.lines', 'line')
    .leftJoinAndSelect('order.channels', 'orderChannel')   // ← 补丁新增:渠道 join(复用同一条查询)
    .where('line.id IN (:...lineIds)', { lineIds: orderLinesIds })
    .getMany();
// Fulfillment is not ChannelAware, so the owning Orders are its only Channel boundary.
// ...
// The length check is not redundant: Array.every returns true for an empty array, so a
// Fulfillment with no owning Order would pass the Channel test.
const ownedByActiveChannel =
    0 < orders.length &&
    orders.every(order => order.channels.some(channel => idsAreEqual(channel.id, txCtx.channelId)));
if (!ownedByActiveChannel) {
    throw new EntityNotFoundError('Fulfillment', fulfillmentId);   // ← fail closed:空集合也拒绝
}
// 检查通过后才进入状态机迁移
const result = await this.fulfillmentStateMachine.transition(txCtx, fulfillment, orders, state);

同一认知陷阱(集合派生的授权谓词在空集合上的行为)在漏洞代码(循环体)与修复者第一版补丁(every)中各出现一次。证据只支持"同一补丁序列内重复出现的审查陷阱",不足以断言跨提交的系统性模式 [Commit]——但作为"空集合授权是一类需要显式防御的模式"的佐证,它比任何理论论述都有说服力。静态审计实验进一步支持该模式可被自动化检索(§14.3②)。

9. 漏洞原语与能力边界

基于已验证实验(均为本地无害夹具),本案形成的安全原语如下——严格区分已验证与理论:

原语

内容

状态

证据

跨渠道状态改写

受限管理员将不可读取的另一渠道 Payment Authorized → Cancelled、Refund Pending → Settled 持久化落库

已验证

[Experiment payment-state-effect / refund-state-effect]

网关命令出站

跨渠道退款生成可被独立 fake gateway 接收的退款命令,关键字段 {action: refund, transactionId: tenant-a-test-transaction, amount: 100}——transactionId 是受害渠道的合成交易标识(完整 JSON 见 §12.3)

已验证(命令形成与出站;非真实资金)

[Experiment gateway-guard]

跨渠道对象创建/删除

订单备注可跨渠道修改;删除实际返回 DELETED;v3.7.2 另可删任意类型历史记录(含状态迁移记录——该维度按规格注释归于 sibling 公告 GHSA-vqq3-xv95-pmf6,§6.6)

已验证

[Experiment channel-scope-matrix][Source][Commit]

审计注入

受害订单 history 被写入归因给跨渠道调用者的 ORDER_REFUND_TRANSITION

已验证(也是检测素材,§13)

[Experiment audit-history-effect]

ID 可观测性与跨渠道连续性(潜在枚举前提)

默认 ID 策略下 ID 十进制、自增跨渠道连续,可从自身合法数据观察全局序列形态;修复版确定性错误字段不提供存在性预言机(反枚举有效)。枚举效率、可达范围与完整枚举能力未验证

已验证(可观测性与反枚举响应两面;"枚举能力"本身是潜在前提,未验证)

[Experiment id-observability / response-equivalence]

履约状态改写 / 库存扰动

所测默认路径上未复现持久化副作用:跨渠道履约迁移在后续父订单操作处报 No Order with the id "1" could be found,状态保持 Pending;跨渠道履约创建前后库存快照 9/2 不变(同渠道对照 9/2 → 8/1 证明观测有效)

未复现(阴性边界)

[Experiment fulfillment-state-effect / fulfillment-inventory-effect]

能力形成链条:

攻击者控制:合法受限管理员会话 + 目标子实体 ID + mutation 参数(金额/状态/备注内容)
        ↓
程序错误处理:全局加载不问渠道;渠道检查因空输入未执行或根本不存在
        ↓
形成原语:对另一渠道订单子实体的状态改写 / 网关命令 / 记录增删
        ↓
最终影响:见 §13 四分级

限制与依赖:不依赖其他漏洞;不提供跨渠道读取原语(渠道 A 订单在该会话不可见——所以这是"盲操作"原语:写可达、读不可达);履约面在所测路径未证明持久化,不能据此宣称全部履约/库存路径无影响;真实支付服务商是否接受该命令、资金是否变动,当前资料不足以确认(本地实验止步于 fake gateway)。

10. Root Cause 与完整形成链路

第一层(架构):渠道边界是约定,不是机制。 数据层不存在自动渠道过滤(§3.1);非 ChannelAware 子实体的渠道归属只能靠调用点手工实现"加载 + 父订单断言"。不变量 I 没有任何强制执行点,它的成立等价于"几百个加载点都记得"。[Source]([Experiment channel-boundary-callsite-audit] 的 17 个无标记加载点是对这一层的量化)

第二层(实现):目标路径的约定被漏掉或写错位置。 支付路径完全没有父订单比对(order 关系加载了却不用);退款路径的渠道检查写在会被空输入跳过的循环体内。[Source]

第三层(模式):授权谓词寄生在业务数据存在性上。 空 lines 让循环体检查未执行、空 orders 让 mismatch 短路——两层"检查未获执行"叠加;修复者第一版补丁又以 every() 形式第三次踩中同一陷阱。[Source][Commit]

完整形成链路(空 lines 退款路径的压缩视图;逐行源码追踪以 §5.1 调用链与 §6.1–6.2 函数体分析为准,此处不重复):

图 7:完整形成链路(空 lines 退款路径压缩视图);逐行源码追踪以 §5.1 调用链与 §6.1–6.2 函数体分析为准,此处不重复

Root Cause = 错误安全假设("按 ID 加载子实体的调用方都会先验证父订单渠道")
           + 实现缺陷(支付路径无断言;退款路径断言寄生在循环体内)
           + 缺失约束(数据层无机制强制渠道过滤;授权层语义不覆盖目标对象)
           + 敏感操作(状态迁移落库 + 支付网关命令出站)

11. Patch Diff 与反向分析

11.1 新原语:assertOrderIsInChannel [Source][Commit]

v3.7.3 在 order-utils.ts 新增了本补丁的核心原语(order-utils.ts:178-195):

// v3.7.3 order-utils.ts:178-195
export async function assertOrderIsInChannel(
    ctx: RequestContext, connection: TransactionalConnection,
    orderId: ID, childEntityName: string, childEntityId: ID,
): Promise<void> {
    const orderIsInChannel = await connection
        .getRepository(ctx, Order)
        .createQueryBuilder('order')
        .innerJoin('order.channels', 'channel')
        .where('order.id = :orderId', { orderId })
        .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
        .getExists();
    if (!orderIsInChannel) {
        throw new EntityNotFoundError(childEntityName, childEntityId);
    }
}

实现上有三个关键选择:

  1. EXISTS 半连接而不是加载整个 Order——断言只需回答"该订单的渠道集合是否包含活跃渠道",多对多关系下这是精确语义,且不引入额外实体加载;
  2. 错误点名子实体而非订单——EntityNotFoundError(childEntityName, childEntityId),注释明言防止用报错探测其他渠道的订单 ID 存在性(反枚举,§11.4);
  3. 签名把 childEntityName/Id 作为必选参数——任何调用点都无法"匿名"断言,从 API 形态上引导调用者给出正确的报错对象。

11.2 各操作面的接入方式 [Commit][Source]

--- v3.7.2 payment.service.ts(settlePayment / cancelPayment / transitionToState fallthrough 三处同构替换,settlePayment 为例)
     async settlePayment(ctx: RequestContext, paymentId: ID): Promise<PaymentStateTransitionError | Payment> {
-        const payment = await this.connection.getEntityOrThrow(ctx, Payment, paymentId, {
-            relations: ['order'],
-        });
+        const payment = await this.getPaymentInChannelOrThrow(ctx, paymentId);

支付三条链被统一收敛到新的私有 helper(v3.7.3 payment.service.ts:238-244):

// v3.7.3 payment.service.ts:238-244(新增)
private async getPaymentInChannelOrThrow(ctx, paymentId): Promise<Payment> {
    const payment = await this.connection.getEntityOrThrow(ctx, Payment, paymentId, {
        relations: ['order'],
    });
    await assertOrderIsInChannel(ctx, this.connection, payment.order.id, 'Payment', paymentId);
    return payment;
}

退款主路径的修复点连同其说明性注释——这段注释本身就是对根因的官方承认(v3.7.3 order.service.ts:1959 起):

--- v3.7.2 order.service.ts refundOrder
     const payment = await this.connection.getEntityOrThrow(ctx, Payment, input.paymentId, {
         relations: ['order'],
     });
+    // An empty `lines` array is a legitimate way to refund shipping or an arbitrary amount, but
+    // it also means the PaymentOrderMismatchError check below has nothing to compare against.
+    // The Channel check is therefore the only thing which keeps the Payment (which is not
+    // ChannelAware) inside the caller's Channel, and it must run before the PaymentMethodHandler
+    // is asked to move any money.
+    await assertOrderIsInChannel(ctx, this.connection, payment.order.id, 'Payment', input.paymentId);
     if (orders && orders.length && !idsAreEqual(payment.order.id, orders[0].id)) {

settleRefund 在事务内断言,保证断言与写入原子(v3.7.3 order.service.ts:2007 起):

--- v3.7.2 → v3.7.3 order.service.ts settleRefund(事务体内)
     const refund = await this.connection.getEntityOrThrow(txCtx, Refund, input.id, {
         relations: ['payment', 'payment.order'],
     });
+    await assertOrderIsInChannel(txCtx, this.connection, refund.payment.order.id, 'Refund', input.id);
     refund.transactionId = input.transactionId;

history.service.ts 的四个改/删入口各自断言父对象,同时补上类型过滤(订单用 assertOrderIsInChannel,客户用 findOneInChannel——因为 Customer 本身是 ChannelAware,可直接用作用域加载原语):

// v3.7.3 history.service.ts deleteOrderHistoryEntry(修复后,摘录,注释为本文所加)
async deleteOrderHistoryEntry(ctx: RequestContext, id: ID, options?: { type?: HistoryEntryType }) {
    const entry = await this.connection.getEntityOrThrow(ctx, OrderHistoryEntry, id, {
        where: options?.type ? { type: options.type } : {},   // 新增:类型过滤(deleteOrderNote 固定传 ORDER_NOTE)
        relations: ['order'],
    });
    await assertOrderIsInChannel(ctx, this.connection, entry.order.id, 'OrderHistoryEntry', id);  // 新增:父订单断言
    ...
}

getOrdersFromLines 的渠道比对改用 idsAreEqual,报错对象从订单改为订单行(v3.7.3 order-utils.ts 循环体):

--- v3.7.2 → v3.7.3 order-utils.ts getOrdersFromLines(循环体内)
-        if (!order.channels.some(channel => channel.id === ctx.channelId)) {
-            throw new EntityNotFoundError('Order', order.id);
+        if (!order.channels.some(channel => idsAreEqual(channel.id, ctx.channelId))) {
+            // Name the line, not the Order: the caller supplied the line id, and naming the Order
+            // would confirm that an Order with that id exists in another Channel.
+            throw new EntityNotFoundError('OrderLine', line.id);

Patch 为什么能切断漏洞链:断言插在"全局加载"与"状态变更/网关调用"之间,直接补上了缺失的谓词 payment.order.channels ∋ ctx.channelId。对空 lines 路径,它不再依赖任何业务数据的存在性——断言的输入是已加载 Payment 的父订单 ID,与 lines 无关,两层真空授权赖以成立的"空集合"前提被整体绕开。运行时插桩实验证实了顺序:v3.7.3 跨渠道请求的事件序列为 load → assert-start(拒绝,无 handler 事件),同渠道为 load → assert-start → assert-ok → handler,v3.7.2 跨渠道为 load → handler(无任何断言事件)[Experiment parent-order-channel-assertion,含插桩前后源码 SHA-256]。

图 8:v3.7.3 修复接入——断言位于全局加载与副作用之间。断言输入是已加载子实体的父订单 ID,与 lines 等集合内容无关,两层真空授权被整体绕开;失败路径不点名订单,构成反枚举设计(§11.4)

11.3 修复的是 Root Cause 吗?

分层回答:

  • 对第二层(漏掉/写错位置的断言):是。6 个补丁相关直接加载点获得同函数断言(Payment 2、Refund 2、OrderHistoryEntry 2),支付三条链收敛为单点 helper [Experiment channel-boundary-callsite-audit]。
  • 对第三层(真空授权模式):getOrdersFromLines 的循环体检查仍保留原位(语义未改),修复靠另加一条不依赖集合内容的独立断言实现——即"不修两层真空,而是让它不再相关"。fulfillment 路径则显式 fail closed(0 < orders.length && 守卫 + 注释)。[Source][Commit]
  • 对第一层(约定而非机制):没有。getRepository() 仍是普通仓库,其余 10 个未标记的直接加载点仍是约定管辖(它们经人工复核未见问题,但那是审查结论,不是机制保证)。补丁是在约定清单里多记了 6 条,并把支付链收敛为一个单点实例(getPaymentInChannelOrThrow)——这是朝机制方向迈的一步,但不是机制本身。另需限定:这 10 个未标记加载点是否属于 sibling 公告的修复面(公告族分界见 §4.1),不在本文核验范围——本文对它们"未见问题"的复核只针对本公告的越权形态,不可据此对 sibling 公告的覆盖面作任何方向的推断。[Experiment channel-boundary-callsite-audit + repair-boundary-audit;"同类土壤仍在"为工程推断,依据仅限该声明的静态审计范围]

11.4 反枚举设计及其验证 [Source][Experiment]

补丁的两处错误命名(assertOrderIsInChannel 报子实体、getOrdersFromLines 报 OrderLine)体现了一个明确的设计目标——堵住"用报错探测其他渠道订单 ID 存在性"的信道。response-equivalence 实验验证了它:v3.7.3 上 settlePayment、settleRefund、transitionFulfillmentToState、updateOrderNote 四个 mutation,跨渠道存在的 ID 与不存在的 ID(T_999999)返回完全相同的归一化响应——HTTP 200、相同 GraphQL path、extensions.code = ENTITY_NOT_FOUND、消息模板仅随实体类型不同 [Experiment,results/20260919T050631Z/]。边界:仅覆盖四个 core mutation 的确定性错误字段;时延差异、插件、自定义错误格式未测。

11.5 修复边界:文档化豁免与静态校准 [Source][Experiment repair-boundary-audit]

补丁对读取路径采取的是"文档化豁免"而非修复——v3.7.3 给两个非作用域读取方法写了依赖全称命题的 JSDoc:

  • PaymentService.findOneOrThrow:"The only core caller is the Refund.lines field resolver";
  • FulfillmentService.getFulfillmentLines:"neither API has a root Fulfillment query"(且注明"引入 root Fulfillment query 时必须加检查")。

这类"唯一/没有"式声明可静态证伪。校准实验在固定语法范围内找到的 direct 调用恰好与声明一致:两版本均 3 条(RefundEntityResolver.lines → findOneOrThrow 一条、FulfillmentEntityResolver.lines/summary → getFulfillmentLines 两条)[Experiment,results/20260919T063908Z/]。入口侧:9 个 Admin OrderResolver → orderService 直接委派完整覆盖官方 11 个矩阵用例;一般化候选查询(不硬编码入口)在 v3.7.2/3.7.3 分别产出 43/45 行候选,通过 9/9 已知入口与 10/10 代表性 source-to-load 对的预注册校准门槛,十个已知对人工复核均见渠道保护 [Experiment,results/20260919T104414Z/]。引用边界(引用以下数字时必须一并携带):候选行不是漏洞计数;查询不追踪参数流、对象别名、检查支配关系;不覆盖插件、非 OrderResolver 入口、多跳包装器;这是已知路径的校准,不是修复完整性证明。

11.6 从 Patch 反推开发者的隐含假设

把补丁的注释串起来读,能还原出开发者修复时的心智模型:他们清楚子实体非 ChannelAware、清楚网关副作用不可回滚("before the PaymentMethodHandler is asked to move any money")、清楚空 lines 会让 mismatch 失效(refundOrder 注释)、清楚 every() 空数组语义(fulfillment 注释)——也就是说,修复时每一环都被理解了。反推:漏洞引入时的隐含假设是"从 Admin mutation 进来的子实体 ID 总是来自一个已经过渠道过滤的父对象视图"(例如管理员先打开自己渠道的订单、再从其 payments 列表拿 ID)。这个假设在 resolver 直接接收裸 ID 的 API 形态下不成立——API 表面把"父对象导航"和"直接 ID 寻址"拉平了,而安全模型没有跟着拉平。

12. 动态验证

全部实验为本地隔离 Docker Compose 环境(§2 基线),按"现象 → 机理 → 效果 → 边界"四层组织。

12.1 授权边界矩阵(channel-scope-matrix)

以 v3.7.3 的官方回归规格 order-child-channel-scope.e2e-spec.ts(25 用例;"11/11"统计口径与公告族分界见 §4.1 范围表)完整覆盖两个版本:v3.7.2 上 17 个用例失败,其中 GHSA 四类操作面的 11/11 个跨渠道用例全部违反修复版断言(支付 4、退款 3、履约 2、备注 2)——跨渠道取消/状态迁移支付与空 lines 退款未被拒绝,跨渠道备注删除实际返回 DELETED,其余动作也未返回修复版要求的渠道边界错误;v3.7.3 上 25/25 通过,同渠道操作与超级管理员正向控制全部可用,排除了"修复破坏正常功能"的解释 [Test][Experiment,results/20260917T112639Z/]。官方规格的断言设计也值得单独一说:跨渠道用例不仅要求抛错,还断言 expect(settlePaymentSpy).not.toHaveBeenCalled()——把"支付网关未被触发"作为一等断言,而不是满足于一个 GraphQL 错误。这一点在回归测试设计上很值得借鉴(§14.4)。

12.2 断言先于副作用(parent-order-channel-assertion)

无副作用运行时插桩(替身记录事件序列,不改授权结果,插桩前后源码 SHA-256 留档):

图 9:无副作用运行时插桩的事件序列对比(实验 parent-order-channel-assertion)。v3.7.2 跨渠道无任何断言事件;v3.7.3 跨渠道在 assert 处拒绝、无 handler 事件;同渠道正对照四事件完整

这组序列把 §11.2 的静态顺序结论提升为运行时可观测事实 [Experiment]。

12.3 网关命令出站(gateway-guard)

独立 fake gateway(internal Compose 网络,仅接收预定义无害 JSON)观测到 v3.7.2 跨渠道退款产生恰好一条命令:

{ "action": "refund", "phase": "cross-channel", "transactionId": "tenant-a-test-transaction", "amount": 100 }

transactionId 是渠道 A payment 预置的合成交易标识——证明命令携带的是受害渠道的交易引用,而非攻击者渠道的。v3.7.3 跨渠道命令数为 0;两版本同渠道合法对照均正常产生命令 [Experiment]。

12.4 状态持久化与审计痕迹(payment-state-effect / refund-state-effect / audit-history-effect)

三个独立实验把"入口可调用"升级为"实体状态落库":

  • 渠道 B 管理员跨渠道 transitionPaymentToState(Cancelled):v3.7.2 返回成功,超级管理员随后读回渠道 A payment 为 Cancelled;v3.7.3 拒绝且保持 Authorized;两版同渠道正对照通过。
  • 跨渠道 settleRefund:v3.7.2 读回渠道 A refund 为 Settled(无害 fake handler 保留 Pending 初态,专门排除"状态机不允许迁移"的假阴性);v3.7.3 保持 Pending。
  • 跨渠道退款结算成功后,渠道 A 订单 history 恰好新增一条 ORDER_REFUND_TRANSITION(Pending → Settled,指向目标 refund),其 administrator.id 正是渠道 B 调用者;v3.7.3 拒绝且 history 不变 [Experiment,results/20260919T041427Z/]。

最后一条同时是检测素材,但能力边界必须写清:该条目只记录 type/data/administrator.id,不记录请求时的活跃渠道;且 Administrator 无单一渠道归属。所以"history 单独可实现检测"不成立——完整检测规则需将 history 条目与外部访问日志中的活跃渠道(渠道令牌/会话上下文)关联后联合判定(§13)。

12.5 版本边界与升级(version-range-boundary / upgrade-no-migration)

同一"空 lines 跨渠道退款"夹具下,v3.7.0、v3.7.1、v3.7.2 都观察到受影响路径("应拒绝"断言失败——跨渠道操作实际未被拒绝),v3.7.3 是所选 v3.7 标签中首个通过者;不外推至更早 3.x。升级侧:v3.7.2 写入的最小 PostgreSQL 数据(2 channel / 3 order / 3 payment)由 v3.7.3 以 synchronize: false、migrationsRun: false 启动并读回完全相同计数,schema dry-run upQueries = 0,读后快照不变 [Experiment]。归因纪律:免迁移是该最小 PostgreSQL 路径的本地观察,不归因为官方声明,也不外推到其他数据库引擎、插件与生产迁移历史。

12.6 诚实的阴性结果(fulfillment-state-effect / fulfillment-inventory-effect)

两项履约实验运行成功、正对照成立,但目标副作用未复现:跨渠道 Pending → Shipped 迁移在后续父订单操作处报 No Order with the id "1" could be found,目标状态保持 Pending(注意:这个错误既不应被误读为迁移成功,也不能误读为渠道拒绝——它的报错点与修复版的渠道拒绝不同);启用库存追踪后,跨渠道履约创建前后快照均为 9/2(同渠道对照 9/2 → 8/1,证明观测本身有效)。结论限定:所测默认路径上未证明履约状态持久化与库存副作用;公告对履约/库存的表述以官方为准,本地只报告所选路径的观察。不放大、也不缩小。

13. 安全影响

按证据强度四分级:

已验证(本地隔离实验)——跨渠道 Payment/Refund 状态持久化改写;退款命令携带受害渠道交易标识出站并被独立处理器接收;跨渠道备注修改/删除(含任意类型历史记录删除);受害订单审计记录归因异常;修复版反枚举有效;修复版同渠道功能不受影响。

源码直接证明——11 个跨渠道入口的加载路径无渠道条件(v3.7.2);全局加载是数据层默认;断言插入点位于副作用之前。

理论可能(未验证 / 证据不足)——真实支付服务商接受该命令并发生真实资金变动(本地止步于 fake gateway);所测路径之外的履约/库存影响;其他插件或非 OrderResolver 入口的同构面;时延侧信道。

当前无法确认——在野利用与 IOC(截至 2026-09-17 检索范围未发现,不构成不存在性证明);更早 3.x 版本是否同样受影响(仅测 v3.7.0–3.7.2);具体部署的暴露面(未提供资产信息)。

检测建议(基于已验证素材,非官方规则):优先复核 ORDER_REFUND_TRANSITION(及同类订单 history 条目)中"订单所属渠道"与"事件发生时活跃渠道"不一致的记录——后者需由访问/审计日志供给(渠道令牌或会话上下文),再按 administrator.id → User.roles → Role.channels 映射联合判定;不得使用"管理员所属渠道"概念。该规则仅覆盖已测的默认退款结算路径。

14. 防御、修复与同类审计

14.1 对本漏洞

  • 根本修复:升级 @vendure/core 至 3.7.3(GHSA 明确的受影响包);套件内各 @vendure/* 包版本对齐与预发布验证作为部署建议呈现。升级后过去可跨渠道运行的合法运维流程会被拒绝,应先在预发布环境验证(本地最小 PostgreSQL 升级路径见 §12.5)。
  • 临时降险(分析推断,非官方替代方案):收紧受限管理员的 UpdateOrder 委派;审计既有跨渠道子实体操作记录(§13 检测规则)。

14.2 可迁移的审计模式

本案沉淀出四个可复用的错误模式,均可静态检索(候选筛选)+ 动态复核确认:

模式一:检查寄生在业务数据上
   for (const x of userProvidedCollection) { authorizationCheck(x); }
   ⇒ 空集合时检查未执行;检索"迭代体内的授权判定 + 集合来自用户输入"

模式二:空集合谓词恒真
   collection.every(authorizationPredicate)
   ⇒ [] 时恒真;检索 every/filter 空数组语义 + 显式非空守卫缺失
   (本案修复者自己都踩了这一坑——最好的防御是把教训写进注释并 fail closed)

模式三:检查对象 ≠ 使用对象
   权限层校验"会话 × 渠道",资源层按裸 ID 全局寻址
   ⇒ 检索"resolver 直接收实体 ID + 实体非租户感知 + 加载后无归属断言"三连

模式四:文档化豁免 = 未验证的全称命题
   "唯一调用方是 X" / "没有 root Y 查询" ⇒ 可静态证伪;修复评审时应把这类注释
   当作待验证断言而非安全论证

14.3 静态检索思路(本案已验证的用法)

CodeQL 查询在本案的用法是候选筛选而非结论:① 枚举非 ChannelAware 子实体的直接 getEntityOrThrow 调用点,按"channelId 选项 / 同函数 assertOrderIsInChannel"分类(17→16 点,6 点获得标记);② 枚举 .every() 回调与循环体内的 channelId 属性访问(v3.7.2 为 2/22 个候选);③ 以已解析调用目标生成"Admin resolver → service 一跳"候选链,用已知 11 个正样本校准召回(9/9、10/10)。所有命中均需人工调用链审查 + 隔离动态验证后才可升级为结论——本仓库三个静态审计实验均按此纪律执行并随文携带范围声明。最后一条边界与全文"不可外推"呼应:sibling 公告覆盖的其余操作面不在本文验证范围(见 §4.1),上述查询与候选结论不可外推到它们。

14.4 回归测试设计

官方规格提供两个直接可借鉴的断言设计:① 把"副作用未发生"作为一等断言(expect(createRefundSpy).not.toHaveBeenCalled()),比"抛出了错误"强一个量级——错误可能是任何原因抛的,spy 未被调用才证明网关边界守住了;② 正负对照成对出现(同渠道必须成功、跨渠道必须拒绝、超级管理员跨渠道合法操作必须仍成功),三者缺一都会让"修复"与"破坏功能"不可区分。

15. 总结

这个漏洞的每一层都值得单独记一笔:

  1. 边界层:渠道租户边界对订单子实体只是约定——getRepository() 没有自动过滤,非 ChannelAware 实体的归属断言全靠调用点自觉。补丁往约定清单里加了 6 条、把支付链收敛为单点 helper,是正确方向上的一步,但机制层面的土壤仍在。
  2. 检查层:授权层语义("会话在活跃渠道是否持有权限")与资源层需求("目标对象的父订单是否在活跃渠道")之间有一道裂缝,v3.7.2 的目标路径全部掉了进去。
  3. 模式层:空集合上的授权谓词——循环体检查未执行、length && 短路、every() 恒真——在同一漏洞与修复者自己的首版补丁中出现了三次。这足以说明它是人类 reviewer 的系统性盲区,也正是静态检索最能创造价值的地方。
  4. 验证层:16 个隔离实验(13 个动态 + 3 个静态审计)把公告结论逐项落到"断言顺序、命令内容、状态落库、审计痕迹"的运行时事实上,同时诚实保留了两个阴性结果——未复现 ≠ 无影响,边界声明与结论本身同等重要。

对同类系统(多租户 SaaS、多店铺电商、多品牌 CMS)的迁移式追问只有一句:你的租户边界,是每个调用点都记得的约定,还是没记得就会失败的机制? 找出系统中"非租户感知 + 按裸 ID 寻址 + 父对象携带归属"的实体清单,答案通常就在里面。

附录 A:摘要级验证路径

本研究的全部夹具与断言位于研究仓库 experiments/ 各目录(每目录含 README.md / REPORT.md / scripts/verify.sh 与运行留档)。本附录给出的是摘要级验证路径——一个拓扑、两条操作、一组判据——供读者理解各主张"如何被证伪"的结构。它本身不是可独立搭建的复现方案:夹具初始化、双渠道配置、测试会话生成与 fake gateway 实现都只在仓库的隔离实验中,本附录不重复、也不提供 endpoint 地址、认证凭据或任何可用于第三方实例的材料。真正的可复现性由公开后的实验仓库承担(发布前置条件见参考资料节);任一判据不成立即证伪对应主张。

拓扑(两个实验的最小并集,全部本地可丢弃容器):

图 10:最小复现拓扑(两个实验的最小并集)。全部容器本地可丢弃;fake gateway 仅接收预定义无害 JSON,其记录不是真实资金结果

两条关键操作(均以渠道 B 管理员会话 + vendure-token: second_channel_token 请求头发送,ID 为渠道 A 的十进制 Payment 主键):

⚠️ 使用限制:以下两条 mutation 仅限在本地、可丢弃、已授权的双渠道测试夹具(本附录所述拓扑)中执行,用于验证版本行为差异;不得对生产环境或任何第三方实例使用,也不应据此构建自动化枚举工具。本文不提供 endpoint 地址、认证令牌或批量枚举实现。

# 操作 1:资金出口原语——空 lines 跨渠道退款
mutation {
  refundOrder(input: { lines: [], shipping: 100, paymentId: "<渠道A paymentId>" }) {
    ... on Refund { id state }
    ... on ErrorResult { errorCode message }
  }
}

# 操作 2:状态改写原语——跨渠道支付取消
mutation {
  transitionPaymentToState(id: "<渠道A paymentId>", state: "Cancelled") {
    ... on Payment { id state }
    ... on ErrorResult { errorCode message }
  }
}

预期现象与判据:

判据

v3.7.2(受影响)

v3.7.3(修复)

操作 1 返回

成功创建 Refund

ENTITY_NOT_FOUND(仅点名 Payment 与其 ID,不泄露订单存在性)

fake gateway 记录

恰好一条 {"action":"refund","transactionId":"<渠道A合成交易标识>","amount":100} 命令(完整形态见 §12.3)

跨渠道命令 0 条

操作 2 返回

state: "Cancelled";SuperAdmin 随后读回渠道 A payment 为 Cancelled(§12.4)

ENTITY_NOT_FOUND;状态保持 Authorized

正对照

同渠道两操作成功;SuperAdmin 跨渠道合法操作成功

同左

任一"受影响版被拒绝"或"修复版成功 / 触发处理器"的观察都证伪对应主张。更严格的判定(处理器 spy 未被调用的一等断言、审计痕迹归因、履约阴性边界)见 experiments/ 各 REPORT.md。

参考资料

官方(证据基线访问日期 2026-09-17;Credits、发布者/发布日期与公告族表述于 2026-09-20 复核确认,见 §1 时间线):

本地源码工作树:source-repositories/vendurehq--vendure/worktrees/(v3.7.0 a2b8ff8b / v3.7.1 d968e114 / v3.7.2 fdbad19f / v3.7.3 2cc309deb,四版本 GitHub 固定 commit 链接见 §2)。

本地实验与证据索引("主张 → 实验 → 结果文件 → 判据")。每个实验目录均含 README.md(目标、前提、复验方式)、REPORT.md(结论与权威运行记录)、scripts/verify.sh(复验入口)与 results/<UTC>/ 运行留档;下表"结果文件"列为 REPORT.md 记录的权威成功运行,其 metadata.txt 载有基础镜像 digest(node:22-bookworm-slim@sha256:…)、Bun 版本、两版本源码 commit 与断言/观察原文(assertion= / observation= 行),"判据"列为该原文的中文摘要。当前状态:本仓库尚未公开(无远程地址),下表与正文中的 experiments/ 路径均为仓库相对路径——按下方"发布前置条件"替换为固定 commit 的公开链接之前,本文不宣称读者可独立复现:

主张

实验

结果文件(权威运行)

判据(摘要;原文见该运行 metadata.txt)

空 lines 跨渠道退款越过渠道边界到达退款处理器

experiments/ghsa-7qvr-c5vf-xxfh-refund-channel-scope/

results/20260917T104134Z/(两版本 stdout-stderr.log)

v3.7.2 到达受影响退款路径;v3.7.3 在支付处理器之前拒绝

四类操作面 11/11 跨渠道断言 v3.7.2 失守、v3.7.3 全过

experiments/ghsa-7qvr-c5vf-xxfh-channel-scope-matrix/

results/20260917T112639Z/

11 项公告相关跨渠道断言在 v3.7.2 全部失败;官方 25 用例套件在 v3.7.3 全部通过

v3.7 补丁线版本边界

experiments/ghsa-7qvr-c5vf-xxfh-version-range-boundary/

results/20260918T102552Z/(v3.7.0)、…T103845Z/(v3.7.1)、…T104216Z/(v3.7.2)、…T102638Z/(v3.7.3),各含 matrix.csv

v3.7.0–3.7.2 观察到受影响路径(退出码 1);v3.7.3 通过(退出码 0)

跨渠道支付状态持久化

experiments/ghsa-7qvr-c5vf-xxfh-payment-state-effect/

results/20260918T111548Z/

v3.7.2 跨渠道 Cancelled 迁移落库到渠道 A 目标;v3.7.3 拒绝并保持 Authorized;两版同渠道对照通过

跨渠道退款结算持久化

experiments/ghsa-7qvr-c5vf-xxfh-refund-state-effect/

results/20260918T112915Z/

v3.7.2 跨渠道 settleRefund 将渠道 A 目标 Pending→Settled 落库;v3.7.3 拒绝并保持 Pending

退款命令到达独立 fake gateway

experiments/cross-channel-refund-gateway-guard/

results/20260918T110649Z/(gateway-records.jsonl)

v3.7.2 恰好一条跨渠道退款命令(tenant-a-test-transaction);v3.7.3 为零;两版同渠道对照均可观测

断言先于副作用的运行时事件序列

experiments/parent-order-channel-assertion/

results/20260918T105718Z/(events.jsonl、instrumentation.txt)

v3.7.2 无父订单断言直达退款 handler;v3.7.3 跨渠道在 handler 之前被拒,同渠道仅在断言成功后放行

履约状态持久化(未复现,阴性边界)

experiments/ghsa-7qvr-c5vf-xxfh-fulfillment-state-effect/

results/20260918T114102Z/

observation:v3.7.2 跨渠道调用与读回如实记录、不预设后续报错是提交还是回滚;v3.7.3 拒绝并保持 Pending

库存副作用(未复现,阴性边界)

experiments/ghsa-7qvr-c5vf-xxfh-fulfillment-inventory-effect/

results/20260918T115502Z/

observation:两版同渠道对照库存各减一;v3.7.3 跨渠道创建在任何库存变化前被拒;v3.7.2 跨渠道观察如实记录、不预设影响

审计痕迹与归因

experiments/ghsa-7qvr-c5vf-xxfh-audit-history-effect/

results/20260919T041427Z/

v3.7.2 跨渠道 settleRefund 在渠道 A 订单写入归因渠道 B 管理员的 ORDER_REFUND_TRANSITION;v3.7.3 拒绝且 history 不变

默认 ID 表示与跨渠道连续性

experiments/ghsa-7qvr-c5vf-xxfh-id-observability/

results/20260919T045603Z/

默认 AutoIncrementIdStrategy 下 Payment/Refund ID 为原始十进制且计数不按渠道重置;渠道 B 仅能读自身订单

修复版反枚举响应等价

experiments/ghsa-7qvr-c5vf-xxfh-response-equivalence/

results/20260919T050631Z/

v3.7.3 四个所测 mutation 上,跨渠道存在 ID 与不存在 ID 的归一化 GraphQL 错误契约等价;时延不在范围

免迁移升级路径(最小 PostgreSQL)

experiments/ghsa-7qvr-c5vf-xxfh-upgrade-no-migration/

results/20260918T121301Z/(old-database-counts.csv、new-database-counts.csv)

v3.7.2 写入的最小 PostgreSQL 数据由 v3.7.3 以 synchronize:false 读回;schema 变更为 0;计数快照不变

渠道敏感子实体直接加载点静态盘点

experiments/ghsa-7qvr-c5vf-xxfh-channel-boundary-callsite-audit/

results/20260919T060529Z/(summary.csv、calls.csv)

每行为六个目标实体在 service/services 的直接 getEntityOrThrow 调用;分类仅限 channelId 选项或同函数 assertOrderIsInChannel;不证明可达性、数据流或漏洞

空集合真空授权候选静态审计

experiments/ghsa-7qvr-c5vf-xxfh-vacuous-authorization-audit/

results/20260919T061339Z/(every.csv、loop.csv)

每行仅为语法候选(every 回调 / for-of 循环提及 channelId);是否授权相关、可否收到空集合、是否状态变更路径均需人工复核

修复边界与文档化豁免静态校准

experiments/ghsa-7qvr-c5vf-xxfh-repair-boundary-audit/

results/20260919T063908Z/ 与 results/20260919T104414Z/(documented-read-callers.csv、admin-mutation-candidates.csv、known-load-chains.csv、generalized-candidates.csv)

读取豁免仅当直接调用与文档声明一致时成立;候选清单仅证明 9 个已知 resolver 委派在位及其服务方法是否含同函数父订单断言;不证明参数级污点流、全部调用方或修复完整性

仓库内研究文档:GHSA-7qvr-c5vf-xxfh.md(主报告)、local-verification-gap-analysis.md(逐项对照)、vulnerability-hypotheses.md(假设与校准记录)、experiment-conclusions.md(实验结论整合)、blog-article-outline.md(本文框架与证据纪律)。

可审计性与发布前置条件:"读者可独立复现"不是本文当前已满足的宣称,而是下列前置条件全部完成、链接固定到发布 commit 之后才成立的发布状态;在此之前,本文的证据边界是"材料齐备于本仓库,公开后即可逐项复验":

  1. 仓库公开并固定基线:公开本仓库并确定发布 commit,将正文与证据索引表中的仓库相对路径替换为该 commit 的公开绝对链接,覆盖:各实验目录及其 README.md / REPORT.md / scripts/verify.sh,与权威运行的 results/<UTC>/ 留档文件。
  2. 权威运行留档随仓库发布:当前 .gitignore 排除 experiments/*/results/*(仅保留 .gitkeep),运行留档默认不随仓库发布;发布前需为证据索引表所列权威运行开放白名单(metadata.txt、run.log 与各版本摘要/断言输出;CodeQL 二进制产物 *.codeql-db、*.bqrs 可继续排除),使镜像 digest 与断言原文可按固定 commit 审阅。
  3. 源码与官方材料核对:源码四版本与修复提交已按 GitHub 固定 commit 链接(§2 与本节官方链接,无需发布动作);官方回归规格已固定于 v3.7.3 commit(本节官方链接);发布时逐行核对证据索引表"判据"列与对应 metadata.txt 的 assertion= / observation= 原文一致。
  4. 最小复验命令链(从克隆到结论,任选一个动态实验目录;<repo-url> / <publish-commit> 两个占位符随第 1 项一并消除):
git clone <repo-url> <repo> && git -C <repo> checkout <publish-commit>
cd <repo>/experiments/ghsa-7qvr-c5vf-xxfh-channel-scope-matrix
./scripts/verify.sh     # 需 Docker Compose 与构建期网络;产物写入本目录 results/<UTC>/

复验纪律:先读该目录 README.md,以 REPORT.md 记录的明确成功运行作为证据;每个实验目录独立成证,不互为替代。

附录 A 是摘要级验证路径,不是独立于本仓库的降级复现方案——离开仓库中的夹具初始化、双渠道配置、测试会话生成与 fake gateway 实现,它只能用于对照理解版本行为差异,不能独立复现。可复现性由完成上述前置条件后的公开固定 commit 实验仓库承担。