Skip to content

fix(rest): :type 段每 handler 归一一次,复数拼写不再绕过 §6.7 audience 门禁 (#6241) - #6348

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6241-meta-plural-gate-bypass
Aug 7, 2026
Merged

fix(rest): :type 段每 handler 归一一次,复数拼写不再绕过 §6.7 audience 门禁 (#6241)#6348
os-zhuang merged 1 commit into
mainfrom
claude/issue-6241-meta-plural-gate-bypass

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes #6241

GET /api/v1/meta/books/:name(复数,PD #3 的规范拼写)整条绕过 ADR-0046 §6.7 的
audience 授权:单条元数据读取 handler 的缓存分支进入条件,对 doc / book 的排除
写的是字面量比较,而 §6.7 门禁写在 else(非缓存)分支里。enableCache 默认 true,
所以出问题的那条路径就是默认路径。这是 fail-open——错误的结果是把门控文档发出去,
不是拒服务。

前提重验(实读 origin/main@026101660,rebase 后含 #6303,逐条成立)

事实 位置(修复前 main) 结论
缓存分支条件对 doc/book 用字面量比较 rest-server.ts:4316 … && req.params.type !== 'doc' && req.params.type !== 'book' 成立
§6.7 门禁只在 else 分支 rest-server.ts:4461 起(audienceGatedType 局部量) 成立
同一 if 里 app / dashboard 是归一化比较 :4252 isAppType:4315 isDashboardType 成立(不对称在一个 if 内)
PLURAL_TO_SINGULAR 含 docs/books spec/src/shared/metadata-collection.zod.ts:144-145 docs: 'doc' / books: 'book' 成立
enableCache 默认 true metadata 块即取默认 成立

排除不是顺手写的整洁,是被字面量比较辜负的安全不变量——条件正上方的注释一直写着:

doc and book bypass the shared cache: their §6.7 audience gate is per-caller,
and a shared ETag would leak gated content across viewers.

这句话在位,而它下面那行放行了复数拼写。

修法:按 #3984 已裁的结构形式,不是在两个错的旁边加第三个对的

#3984 裁的是「每个 handler 顶部归一一次,后续所有闸门都用归一后的值」。本 PR 就按这个形状做:

const metaType = RestServer.metaTypeSingular(req.params.type);   // :4245,handler 顶部
const isAppType = metaType === 'app';                            // :4288
const isDashboardType = metaType === 'dashboard';                // :4366
const isAudienceGatedType = metaType === 'book' || metaType === 'doc';   // :4370

一个额外的收敛值得单说:缓存排除与 §6.7 门禁现在读同一个谓词 isAudienceGatedType
(:4371 的排除、:4516 的门禁)。绕开缓存的唯一理由就是让那道门禁可达,所以「哪些类型绕缓存」
和「哪些类型受 audience 门禁」本就该是同一个事实;以前它们是两处独立书写的清单,可以各自漂移——
这次复发正是漂移的产物。将来第三个受门禁的类型只需改一处,两个站点同时跟上。

残余 req.params.type 比较清点(全文件,逐条处置)

改前全仓 rest-server.ts 里的字面量比较只有 :4316 那两处(其余按类型的闸门在 #3984 时已归一)。
改后全文件零处代码级 params.type ===/!== 比较,node scripts/check-meta-type-normalized.mjs 实测确认。

handler 内剩下 5 处 req.params.type,全部是透传,不是判断,逐条给出不改的理由:

站点 为什么保持原样
:4272 getMetaItemLayered({ type: … }) 交给 protocol,protocol 自己折叠复数(protocol.ts 多处 PLURAL_TO_SINGULAR[type] ?? type)
:4386 getMetaItemCached({ type: … }) 同上
:4445 translateMetaEnvelope(req, …)(缓存分支) 见下「顺带发现」——这里改一处会与列表 handler / 复合名 handler 不一致,属另一条缺陷
:4454 getMetaItem({ type: … }) 同上,protocol 折叠
:4579 translateMetaEnvelope(req, …)(非缓存分支) :4445

判据是:REST 层不得拿未归一的值做判断;把原始拼写交给自己负责归一的下游(protocol)是正确的,
Prime Directive #12 的方向也在这边——不在消费侧加宽容,而是让归一发生在拥有该职责的那一层。

陷阱与取舍:复数 doc/book 失去 ETag 快路径

归一化后 docs / books 复数读取从缓存分支移到非缓存分支(单数拼写一直如此),于是这两类响应
不再带 ETag / Cache-Control,条件请求也不再答 304。与 #5881dashboard 的取舍同源,
理由更硬:

  • dashboard 那次的理由是「ETag 表达不了门禁维度」;这次是注释里早就写明的跨调用方泄露——
    ETag 是对未过滤文档的哈希,per-caller 的门禁结果根本进不了这个验证器;
  • 代价近乎零:getMetaItemCached 本就委托给 getMetaItem,服务端两条路做的是同样的工作,
    让出的只有 304 省下的响应体字节。

其它元数据类型不受影响(阳性对照见下)。

既有测试重判:零个需要改,但原因值得写下来

全仓扫过没有任何既有用例钉住「复数 doc/book 走缓存」。不是因为覆盖到了,而是因为看不见:
meta-audience-plural.test.ts(#3984 的钉子)与 meta-item-envelope.test.ts 的 protocol double
不提供 getMetaItemCached,于是每次读取都落到非缓存分支——那条分支上门禁一直是好的,
测试也就一直是绿的。#3984 的钉子测的是真实缺陷,却测在一个默认部署根本走不到的分支上。
这与 #5881 对 dashboard 的假绿是同一个形状,本 PR 的新用例正是补这个洞:新 describe 用
提供两条读取的 double(默认部署的样子)。

packages/cli / packages/lint 里命中 books 的两个文件已实读,是 manifest 字段名与 lint fixture,
不消费本 handler,无需扫动。

反向验证(先预测方向,再跑)

预测:恢复字面量比较后,复数钉子转红;单数钉子保持绿(字面量对单数仍然成立);
非门控类型的阳性对照保持绿;「未被认领的 doc 仍可读」这条会绿,但绿得没有信息——
它两种实现下都是 200。预计 3 红 / 7 绿。

实测(vitest run src/meta-audience-plural.test.ts):3 failed | 7 passed,方向与逐条身份都吻合。

× a {permissionSet}-gated book is 403 on the PLURAL spelling, not 200
  AssertionError: expected 200 to be 403
× a doc claimed only by the gated book is 403 on /meta/docs/:name too
  AssertionError: expected 200 to be 403
× the price of the bypass, pinned rather than hidden: gated reads carry no ETag
  AssertionError: expected [ 'ETag', 'Cache-Control', 'Vary' ] to not include 'ETag'

第三条的输出是这次最有说服力的证据:它直接显示门控 book 是带着对未过滤文档的共享 ETag
发出去的——正是注释里那句 "a shared ETag would leak gated content across viewers" 描述的机制。
验证后已还原,复跑全绿。

门禁自身也做了同向验证:恢复字面量后 check:meta-type-normalized 报 2 处
(rest-server.ts:4345 [comparison] req.params.type !== 'doc' / !== 'book'),exit 1;还原后 OK。

搭载的 check:* 门禁(分诊第 2 条建议)

scripts/check-meta-type-normalized.mjs + package.json 一条 script + ESLint job 内一步。
代价确实小,所以同 PR 搭载。三点设计说明:

  1. AST 而非 grep,这是必须的而不是讲究:rest-server.ts 的多段 JSDoc 引用了坏模式原文
    (req.params.type === 'book')来解释历史,文本扫描会把复盘记录本身判红,逼下一个写复盘的人
    把话说拧。AST 看不见注释,守卫与史料因此可以共存(self-test 里专门钉了这一条)。
  2. 覆盖面照实说:===/!==/==/!= 两侧、switch 判别式、includes/has/indexOf 成员测试。
    不覆盖先赋值给局部再比较(需要数据流分析)——脚本头部明写,不含糊其辞。
    透传不算判断,不报(理由同上表)。
  3. 豁免清单今天是空的:本 PR 摘掉了 packages/rest/src 最后一处裸比较,所以这道门禁从零起步,
    不是从 ratchet 起步。

史实(查证后才写)

分诊留了个未断言的问题:缓存分支早于还是晚于 #3984 的修复。本 PR 不陈述该结论——
git log/git blame 未做到足以下断言的程度,而按纪律「不确证就不写」。它不影响修法。

验证

pnpm --filter @objectstack/rest test              → Test Files 64 passed, Tests 881 passed(rebase 后)
npx eslint --no-inline-config 3 个改动文件         → 无输出,exit 0
node scripts/check-meta-type-normalized.mjs       → OK(15 file(s), no raw `:type` param decisions)
check:nul-bytes / route-envelope / error-code-casing /
  empty-changeset / workflow-status-functions /
  authz-resolver / adr-anchors / wildcard-fallthrough /
  type-check-coverage                             → 全部 PASS
tsc --noEmit -p packages/rest/tsconfig.json       → 2 条 TS2345,与 origin/main 同两处
                                                    (package-routes.ts:207/279),本改动新增 0 条

packages/resttypecheck 脚本(type-check-coverage 的 DEBT 条目,记 errors: 2),故手工跑 tsc 对基线。

顺带发现(不在本 PR 修,另立单)

同一批 handler 把原始拼写透传给 translateMetaItem,而 isTranslatableMetaType 查的是
TRANSLATABLE_METADATA_TYPES —— 一个只含单数的集合(view/action/object/app/dashboard/page)。
于是规范的复数拼写拿不到本地化。实测(真 RestServer.translateMetaItem,zh-CN bundle):

singular "app"  label = "XLABELX"   ← 已翻译
plural   "apps" label = "Setup"     ← 未翻译

同属 #3984 的「拼写敏感」家族,但不是授权问题(是 i18n,观感级),且落点跨三个 handler 加一个共享
helper——修在本 PR 里既越界又只会修一半。已另立单跟踪,本 PR 保持透传原样。


Generated by Claude Code

…al spelling cannot skip the §6.7 audience gate (#6241)

The single-item metadata read's cached branch excluded `doc` / `book` by
comparing the RAW `:type` path segment against singular literals. The route
serves both spellings and Prime Directive #3 makes the plural one canonical, so
`GET /api/v1/meta/books/:name` did not match the exclusion, took the cached
branch, and the ADR-0046 §6.7 audience gate — which lives in the uncached
branch — never ran. `enableCache` defaults to true, so the failing path was the
default one, and the failure was fail-open: a `{ permissionSet }`-gated book was
served in full to a signed-in caller holding no set.

This is #3984 recurring in the same file eight days later, so the fix takes the
structural form #3984 already ruled rather than correcting two literals: the
handler normalizes once at the top (`metaType`) and every gate below reads that
local. The cache exclusion and the §6.7 gate now share one predicate
(`isAudienceGatedType`), so they cannot drift apart.

Also adds `check:meta-type-normalized` — an AST-based guard (comments invisible,
so the file's own post-mortems still quote the bad pattern) refusing any raw
`:type` comparison, switch discriminant or membership test in packages/rest/src.
Zero exemptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 2:32pm

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tests tooling labels Aug 7, 2026
@qq9340100
qq9340100 marked this pull request as ready for review August 7, 2026 14:49
@qq9340100
qq9340100 added this pull request to the merge queue Aug 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31189600460 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 75 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-zhuang
os-zhuang added this pull request to the merge queue Aug 7, 2026
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Queue steward — re-queued as-is (队列管家原样重投). Ledger row: cross-repo common table → "GitHub Actions runner lost / npm registry 5xx / network timeout (infrastructure jitter, unrelated to the diff)" ⇒ known environment jitter ⇒ re-queue unchanged.

No code change is being asked of the lane, and this seat changed nothing on the PR beyond re-arming auto-merge: no rerun, no ready/draft flip, no label or claim change.

Full signature (taken from the complete job archive, ⛔ not the tail — SKILL note 7)

Run 31189600460, generation pr-6348-d8e8d9cb…, created 14:50:17Z. Two jobs reported failure; they are one root cause, not two:

1. Dogfood Regression Gate (1/3) (job 92902811766) — failing step Verify pnpm version, 12 seconds into the job:

! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.31.0.tgz
Error: Error when performing the request to https://registry.npmjs.org/pnpm/-/pnpm-10.31.0.tgz
    at fetch (…/corepack/dist/lib/corepack.cjs:22089:11)
  [cause]: TypeError: fetch failed
    [cause]: Error: read ECONNRESET
        at TLSWrap.onStreamRead (node:internal/stream_base_commons:216:20) {
      errno: -104, code: 'ECONNRESET', syscall: 'read'
    }
##[error]Process completed with exit code 1.

2. Dogfood Regression Gate (aggregate, job 92904853013) — a consequence of the above, not an independent failure:

leg dogfood (declared roster 1..3/3) — aggregate result: failure
  - dogfood-1-of-3  MISSING
  + dogfood-2-of-3  (run 31189600460, attempt 1)
  + dogfood-3-of-3  (run 31189600460, attempt 1)
##[error]… 1 of 3 declared shard(s) of dogfood published no positive attestation … see #6082

Why this is diff-independent (the ledger row's defining property)

  • The failure lands on pnpm --version — corepack fetching its own tarball from registry.npmjs.org. This is before pnpm install, before any build, before any repo script executes. No file in this PR is reachable from that step.
  • Shards 2/3 and 3/3 of the same run, on the same merge commit, ran the same corepack step and both succeeded — the failure is per-runner transient network jitter, not a property of the commit.
  • ECONNRESET on a TLS read against the registry is a transport-level reset, squarely the "network timeout / infrastructure jitter" case.
  • The completeness check printed No test log — the test step did not get far enough to produce one. and exited 0. Per note 7's first clause that is not evidence the suite passed — it is consistent with a job that died before producing a log, which is what happened here.

The bot triage comment above lists the failing step as "日志不可读,点进 job 看" (log unreadable); the archive above is that log.

State readings

reading value
Previously enqueued? yes — generation pr-6348-d8e8d9cb… existed at 14:50:17Z (precondition for re-queue)
Kicked out? yes — absent from the queue chain at 15:20Z, and origin/main does not contain it (two readings, note 1)
Yield window (last 30 min of comments) lane's most recent action is the 14:49:51Z ACCEPT on #6241, i.e. before the 14:50Z failure; no lane disposition after it ⇒ no yield, this seat acts
After re-arming auto-merge queue branch pr-6348-d48aad56… present ⇒ back in the queue. (The REST auto_merge field reads null — that is the note-1 artifact, the queue entry supersedes the pending auto-merge; ⛔ never read that field for queue membership.)

Nothing here changes the fix's scope, so per note 2 this hit is not appended to any flaky issue — plain hit-counting does not earn a timeline entry.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /meta/books/:name(复数拼写)绕过 ADR-0046 §6.7 audience 门禁 —— 缓存分支的 doc/book 排除写的是字面量比较

3 participants