Civitai App Blocks 暗启动与 GA 收尾实战:zip-bomb 上限、submit-version CSRF 与 per-user Buzz 上限的架构化落地
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
App Blocks(产品名Civitai Apps,代码侧仍沿用block/app_block技术词汇)是 civitai 仓库中一套第三方 iframe 应用系统:block 以托管 iframe 形态渲染在模型页插槽与全页应用路由上,通过绑定到单次安装的短时 RS256 JWT 鉴权。本文以交接文档 docs/features/app-blocks-ga-handoff.md 为主体,结合审计文档 docs/features/app-blocks-merge-audit-2026-06.md 与仓库源码,完整拆解这套"暗启动(dark launch)→ GA 阻断项清零 → 上线决策"的工程过程。读完本文,你将掌握:一次第三方代码托管系统上线前,安全 GA 阻断项(zip-bomb 上限、CSRF、每用户每日 Buzz 支出上限)如何从威胁建模走到原子化实现,以及多标志位(Flipt)门控、无信任推送(no-trust-on-push)等关键不变量如何落进生产代码。
背景:基础已合入、全线暗启动
截至 2026-06-14 交接时刻,App Blocks v1 基础已合入main并在生产环境全量部署,处于完全暗启动状态(feature 关闭,仅审核员可见)。2026-06-13 的 session 已清掉安全类 GA 阻断项;2026-06-14 的 session 关闭了剩余三个代码类 GA 阻断项:zip-bomb 上限(#2528)、submit-version CSRF(#2529)、per-user Buzz 上限(#2530)。
已合入main的 PR 清单如下(全部随release 5.0.1830发布):
| PR | 内容 |
|---|---|
| #2319 | App Blocks v1 基础(block-host 基座、CORS、JWT、publisher-install、model.sidebar_top插槽) |
| #2510 | build-callbackwebhook:flag 门控 + 原子重放防护(setNxKeepTtlWithEx) |
| #2517 | Showcase 图片按观看者浏览等级过滤;匿名用户强制为public(PG)等级 |
| #2519 | H2— 服务端/客户端 flag 门控上下文统一(buildFliptContext) |
| #2521 | BLOCK_INIT载荷最小化为 allowlist;移除viewer.status |
| #2524 | git-push不再自动批准/部署 —— 改为审核员审核后触发 |
| #2528 | ZIP zip-bomb 上限—— 逐条目流式解压 + 累计总量上限 |
| #2529 | submit-version CSRF—— 共享同源 allowlist 应用于原始路由 |
| #2530 | per-user Buzz 上限—— 原子 reserve-and-refund 的每用户每日聚合 |
| #2532 | 仅测试侧 typecheck 修复(此前main上已存在的红态导致每个 PR 的 preview 失败) |
三个 GA 阻断项 PR仅涉及代码与 Redis,没有新增需要手工执行的 DB migration。
Flag 状态与暗启动边界
线上 Flipt 状态
依据civitai/flipt-state(GitOps 源)中civitai-app/default/features.yaml的配置,app-blocks-enabled的结构是:
- key: app-blocks-enabled enabled: false # base = OFF rollouts: - segment: { keys: [moderators] } # moderators segment: isModerator == "true"即对所有人关闭;仅当按用户求值且命中moderators分段时,对审核员开启。审计文档明确记录了这一点,并强调 H1 风险(全局enabled: true会让所有人看到 App Blocks)已被moderators分段 + basefalse的组合规避。
三段式 flag 模型(源码级)
在 src/server/services/app-blocks-flag.ts 中,App Blocks 由三个相互独立的 Flipt flag门控,各自对应不同面:
app-blocks-enabled— 用户可见性:basefalse+moderators分段,仅在携带审核员上下文求值时返回true。管辖 UI 挂载、tRPC 门控、令牌签发(mint)、listForModel。app-blocks-pipeline-enabled— 构建/发布流水线(对应开放决策 1):机器 webhook(build-callback、git-push、workflow-completed)无用户上下文,必须用全局 flag 求值。见isAppBlocksPipelineEnabled()。app-blocks-runtime-enabled— 运行时令牌验证(对应开放决策 4):JWKS 公钥端点与withBlockScope中间件验证已签发 block JWT 的面。它必须与流水线 flag 解耦——"暂停构建"不能杀死"线上 block 的运行时令牌验证"。
该文件顶部有一段非常值得读的GLOBAL-EVAL SEMANTICS注释:当无用户求值时,Flipt 以entityId='global'、空 context 命中 flag 的base 值(不是"分段必然不匹配所以关闭")。也就是说:
- flag 缺失或 Flipt 不可达 →
isFlipt捕获异常返回false(这是无条件的 fail-closed); - 而"分段不匹配"是否等于关闭,取决于 base 值——base
true时全局求值就是true。 这解释了为什么isAppBlocksEnabled({ user })的 per-user 分支要把entityId设为用户 id、context 由共享的buildFliptContext(user)构建,从而与客户端getFeatureFlags门保持同一形态、永不漂移;也解释了为什么isAppBlocksAuthorEnabled的user参数是必填(capability 没有主体就无法授权,缺失主体是编译期类型错误,而不是运行时静默false)。
关键不变量
- No-widening(已验证):用户面服务端门控通过
buildFliptContext携带请求用户上下文求值;只有服务端isModerator == true才解析为 TRUE,非审核员/匿名恒为 false;求值缓存以身份为 key,审核员的 TRUE 无法被重放给非审核员。 - 机器/流水线门控留在全局求值上(webhook、JWKS、
withBlockScope):它们没有用户上下文,因此构建/发布流水线仍需全局开启——这是有意的,等待专门的app-blocks-pipeline-enabledflag(见开放决策)。 - No-trust-on-push:
git-push永远不触发部署;构建/部署只能由approveRequest(审核员路径)触发;未审核的 push 进入pending(HTTP 202)评审状态。 - Showcase 与
BLOCK_INIT不再向第三方 iframe 泄露 NSFW 内容/提示词/PII/审核状态。
GA 阻断项一:ZIP zip-bomb 上限(#2528)
威胁
审计发现publish-request.service.ts原先对 ZIP 每个条目完整解压后才做大小检查:jszip 的entry.async('nodebuffer')会先把解压后的全部字节物化到内存,再跑 length 检查——一个膨胀到数 GiB 的单条目能在事后检查触发之前就打爆 Pod。数学上,50 MiB 上传 × 2000 文件 × 10 MiB/文件 ≈ 20 GiB 的解压量,一次上传即可导致 OOM。(zip-slip 不可利用:jszip 会规范化..,文件进入 Forgejo content API。)
修复实现
在 src/server/services/blocks/publish-request.service.ts 中,extractBundleMetadata改为逐条目流式解压:用entry.nodeStream读取,任何时刻超过单文件上限(10 MiB)或运行累计上限(MAX_TOTAL_DECOMPRESSED_BYTES = 200 MiB)就立即中止。驻留内存被约束在约一个单文件上限以内,与压缩比无关。同一个 helper 同时守护 review-push 与 approve-fetch 循环。
常量定义在 src/server/schema/blocks/publish-request.schema.ts:
export const MAX_BUNDLE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MiB export const MAX_FILES_IN_BUNDLE = 2000; export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MiB per file // Ceiling on TOTAL decompressed bytes across all entries in a bundle. // Defends against zip bombs: MAX_FILES_IN_BUNDLE * MAX_FILE_SIZE_BYTES is // ~20 GiB, far past what a pod can hold. 4x the 50 MiB upload cap is well // above any legitimate web bundle's decompressed size yet bounds memory. export const MAX_TOTAL_DECOMPRESSED_BYTES = 200 * 1024 * 1024; // 200 MiBremainingTotalBytes由调用方传入,作为运行中的全局预算(总量上限减去已消费字节),从而让"单文件超限"与"累计超限"都走同一套流式中止路径。
测试佐证
src/server/services/blocks/tests/publish-request.service.test.ts 覆盖了extractBundleMetadata的行为:正常 bundle 的文件清单与 manifest 解析、缺失block.manifest.json拒绝、空 bundle 拒绝、非法 JSON manifest 拒绝、sha256 确定性、路径排序,以及"单文件超过 10 MiB 上限即拒绝"等用例。
同类风险的后续项
审计还点名了同类风险的一处:src/server/services/wildcard-set-provisioning.service.ts:260对用户上传 ZIP 使用entry.async('uint8array'),目前只靠"声明未压缩大小"的预求和上限兜底——而中央目录是攻击者可伪造的,撒谎条目仍可 OOM。建议套用 #2528 的流式上限模式(作为独立 feature 跟踪,风险低于 App Blocks 本身)。
GA 阻断项二:submit-version CSRF(#2529)
威胁
生产环境的会话 cookie 是sameSite:'none'(见next-auth-options.ts),而ModEndpoint原先不做 Origin/CSRF 检查;Next.js 又能解析application/x-www-form-urlencoded,于是攻击者可以构造一个跨站表单 POST,借已登录审核员的 cookie 提交 bundle。这是全站ModEndpoint的既有姿态(并非该路由独有),只是当时被暗启动遮住了。
修复实现
submit-version是一个绕过 tRPC 管线的原始路由(src/pages/api/blocks/submit-version.ts),不会经过createContext的同源检查。修复方式是让该路由调用共享的isAllowedOriginRequest(来自 src/server/utils/origin-helpers.ts 的共享同源 allowlist,createContext与原始路由共用同一份):
if (isProd && !isAllowedOriginRequest(req)) { res.status(403).json({ message: 'Cross-origin request blocked' }); return; }!isProd豁免是为了保住本地开发与测试(它们不发送 Origin),与createContext行为一致。该路由随后执行两层门控:isAppBlocksEnabled({ user })(H2:携带已认证审核员的上下文求值,使moderators分段解析为 ON,镜像enforceAppBlocksFlag)以及 bundle 存储配置检查(BUNDLE_S3_ENDPOINT/BUNDLE_S3_BUCKET缺失时返回 412)。
为什么单独建一条 72mb 路由
bundle 是 base64 编码的 ZIP(50 MiB 上限 → 约 67 MiB 编码后),超过共享 tRPC body 限制。与其把全站唯一的/api/trpc/[trpc]路由上限从 17mb 提到 72mb(等于为所有 tRPC 调用放开限制),不如让上传走这条独立路由,把 72 MiB 的 body 上限隔离在唯一需要它的端点上。这是审计文档中一条已 RESOLVED 的 MEDIUM 的落地形态。
剩余同类风险(跟踪项)
#2529 是刻意按路由收窄的。其他绕过createContext同源检查的 cookie 认证 POST/PUT 路由仍然存在,包括(HTTP 方法已验证):mod/set-image-nsfw-level、mod/csam-upload、mod/clavata-image-process、mod/scanner-policies/export-dataset、mod/new-order/rate-limit-config(PUT)、admin/manage-sanity-checks、admin/temp/membership-buzz-backfill。文档给出的一处式修复方案:在endpoint-helpers.ts的ModEndpoint/AuthedEndpoint里统一加if (isProd && !isAllowedOriginRequest(req)) return 403,并为 bearer/API-key 调用方提供镜像createContext的豁免。(带副作用的 GET 路由如auth/impersonate是另一类问题,不归 Origin allowlist 管。)
GA 阻断项三:per-user Buzz 上限(#2530)
威胁与目标语义
原先的每日上限BLOCK_BUZZ_CAP_PER_DAY = 50_000是按(user, app_block, day)维度计数的——一个用户装 N 个 block,总支出可以放大 N 倍(N-blocks × cap 乘法问题);且旧的"先读后记"(read→record)模式在 Redis 抖动时会少计(TOCTOU / under-count),属于 fail-open。
修复实现:原子 reserve-and-refund
新的上限是per-USER-per-day 聚合,语义上不再区分(user, app_block):
- 入口处用原子
INCRBY预留(reserve); - 超上限或预解析抛错时用
DECRBY退款(refund); - 由此同时关闭"N-block 乘法放大"与"旧 read→record 的 TOCTOU/少计"两个洞;
- Redis 丢失时 fail closed(拒绝支出);丢失退款只会多计(更严格,安全);
- 退款钉死在预留时使用的 key上,避免 UTC 午夜翻转时 DECRBY 误减第二天的 key。
相关实现分散在 src/server/services/blocks/buzz-attribution.service.ts、blocks.router.ts(BLOCK_BUZZ_CAP_PER_DAY、reserveBlockBuzzSpend/refundBlockBuzzSpend)中。审计文档(docs/features/app-blocks-merge-audit-2026-06.md)确认该上限是spend 侧的 GA 前置条件:在费率卡(rate-card)签署 +internalAppOwnerUserIds填充之前,不得接线 payout——mintPayoutForOwner是一个有意的桩,保持惰性。当前appOwnerShareCents恒为 0,因此reserveAppBountyAccrual会短路(见app-bounty-cap.service.ts与app-cap-limits.constants.ts:per-user cap 50_000 Buzz/天 ≈ $50/天,placeholderspendSharePct = 5%下单个满额用户最多向某 app 累积约 $2.50/天)。
非阻断 LOW
- 超限错误的"already spent"数值在并发下可能瞬时高估(仅展示层影响);
decrBy缺少incrBy那样的模板化 key 类型(外观问题)。
开放决策(GA 前后需要产品/工程拍板)
- 流水线门控:为机器端点(webhook/JWKS)建专门的全局
app-blocks-pipeline-enabledflag,还是全局打开用户 flag?内部 webhook 无法求值 mod 分段的用户 flag(无用户上下文),所以在二者之一落定前流水线保持暗态。 git-push更新路径:是把submitVersion(ZIP 上传 → 审核批准 → 部署)定为 block 更新的唯一正典路径(git-push纯作评审触发),还是构建"approve-from-repo"路径让git-push记录的 pending 行可部署?现状是git-push记录的 pending 行无法被approveRequest批准(它没有 MinIO ZIP bundle——它指向 Forgejo 仓库)。两种方案在安全上都成立(没有任何未审核内容会部署)。BLOCK_INIT契约移除viewer.status:与@civitai/app-sdk/ blocks-react 负责人确认(GA 前无外部 block,现在移除是安全的)。withBlockScope留在全局求值:如果审核员对已安装 block 的狗粮测试需要 per-user 路径(经由已验证 JWT 的 subject),那是后续项。
上线前检查清单(翻转 flag 时)
- 在每个环境预检
kill_per_model_installsmigration(join-count 必须为 0,查询语句在审计文档中),并确认所有 app-blocks migration 已应用。 - 解决决策 1(流水线门控)与决策 2(git-push 更新路径)。
关闭 MEDIUM GA 阻断项—— 已完成(#2528/#2529/#2530)。- 在费率卡签署前保持 payout 惰性。
- 刻意设计 Flipt 灰度(mod 分段 → 逐步放宽)——记住,全局
enabled: true会让所有人暴露(H1 风险);用分段来放宽,而不是改 base。
下一 session 的工程方法(含踩坑记录)
- 代码 GA 阻断队列已清空。剩余 GA 工作是开放决策(流水线门控、git-push 更新路径)、payout/费率卡签署与 flag 灰度本身——这些是产品/工程决策,不是代码任务。
- 收敛良好的工作模式:派发一个
isolation: "worktree"子代理实现(PR + 测试),再派只读审计子代理检查risks/regressions/leaks/second-order,迭代到审计收敛。务必针对修复后的状态跑第二轮审计——本 session 的第二轮就抓到一个修复引入的回归(一个stream.destroy()"polish" 既过不了 CI typecheck 又不生效,回退为pause())。 - 直接核验子代理的说法——多处在第一轮就是错的(一个"碰巧工作"的 flag 求值;一次 Redis 返回值误读;一次把 GET-only 路由当成 POST-CSRF 洞的审计;一次审计引用了错误文件路径的真实发现)。
- 在 worktree 里跑测试:
ln -s <local-path>/workspace/civit/civitai/node_modules ./node_modules,然后只跑单个目标文件。worktree 里全量tsc很吵(无关文件里的陈旧 Prisma client)——但可以这样验证单个文件:npx tsc --noEmit -p tsconfig.json 2>&1 | grep <file>(陈旧 Prisma 错误都在其他文件里;CI 会生成新 client)。不要只信 vitest 绿——esbuild 会剥掉类型,真实类型错误(如方法不在声明接口上)能过 vitest 却在 CI 的tsc挂掉。CI 才是权威。 - 跨无关 PR 的 preview 失败先怀疑红态
main:Tektonpreview / deploy以 Type Check 为门,main上任何一个既有的 typecheck 错误会让每个PR 的 preview 失败。gh pr checks <pr>→ 先看 Type Check,别急着认定是自己改动的问题。 - 不堆叠 PR(见 CLAUDE.md):每个 PR 直接基于
main;若改动依赖未合入的修复,等它合入后再合入main(或折叠进一个 PR)。
关键文件索引
- Flag 门控:src/server/services/app-blocks-flag.ts(三轴 flag 模型 + GLOBAL-EVAL SEMANTICS)、src/server/services/feature-flags.service.ts(
buildFliptContext)、blocks.router.ts/apps.router.ts(enforceAppBlocksFlag)。 - Showcase:src/server/services/blocks/showcase.service.ts(按浏览等级过滤
nsfwLevel,匿名强制 public 等级)。 - Iframe 载荷:src/components/AppBlocks/IframeHost.tsx、
projectBlockInit.ts。 - 发布/构建流水线:src/pages/api/internal/blocks/git-push.ts、src/pages/api/internal/blocks/build-callback.ts、src/pages/api/internal/blocks/workflow-completed.ts,以及
src/server/services/blocks/下的publish-request、apps-pipeline、forgejo服务。 - Bundle 上传 + CSRF:src/pages/api/blocks/submit-version.ts(72 MiB body 上限隔离 + 同源 403)、src/server/utils/origin-helpers.ts(共享同源 allowlist,
createContext与原始路由共用)。 - Buzz/金钱:src/server/services/blocks/buzz-attribution.service.ts、
rate-card.ts、blocks.router.ts(BLOCK_BUZZ_CAP_PER_DAY、reserveBlockBuzzSpend/refundBlockBuzzSpend)、src/server/schema/blocks/publish-request.schema.ts(bundle/文件/总量上限常量)。 - 架构正典:docs/features/app-blocks.md(概念、表结构、令牌/scope、
BLOCK_INIT契约、block↔host 消息清单、发布生命周期)、docs/features/app-blocks-merge-audit-2026-06.md(规范审计 + GA 阻断项清单,建议先读)。
结语
这套 GA 收尾的关键不在"修三个 bug",而在把三个威胁分别收敛到可论证的不变量上:zip-bomb 用流式解压把驻留内存与压缩比解耦;CSRF 用共享同源 allowlist 让原始路由与 tRPC 管线姿态一致;Buzz 上限用原子 reserve-and-refund 把"并发下的计数正确性"交给 Redis 原语而不是业务逻辑。配合三轴 Flipt 门控与 no-trust-on-push 的发布生命周期,App Blocks 得以在完全暗态下长期运行、逐段灰度,而不把任何用户面暴露建立在"碰巧没被攻击"上。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考