Repository navigation
finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601
Description
Filed from the ADR-0106 D5(4) implementation-time outlet audit (#3682). Out of scope there — this is an anonymous, author-declared publication surface, not a per-caller FLS projection, so its fix is a different shape.
What was measured
GET /api/v1/forms/:slug (packages/rest/src/rest-server.ts) embeds the target object's schema alongside the form so an anonymous front-end can render without an auth-protected /meta lookup. The narrowing is the set of fields the form's sections declare:
const allowed = new Set<string>();
for (const sec of match.form?.sections ?? []) {
for (const f of sec?.fields ?? []) { /* … collect */ }
}
const fields: Record<string, any> = {};
for (const [name, def] of Object.entries(obj.fields)) {
if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) continue;
if (allowed.size === 0 || allowed.has(name)) {
fields[name] = def;
}
}allowed.size === 0 — a form with no declared sections, or sections that declare no fields — falls through to every non-server-managed field of the object, published anonymously, complete with labels, types and picklist option values. The safeForm stripping below it only filters the form's own sections (lookups without publicPicker); it does not narrow objectSchema.fields, which is a separate key on the same response.
The comment above the block says the schema is "limited to fields referenced by the form" and notes the submit handler enforces the field whitelist server-side. The first half is not true in the zero-sections case, and the second half is about writes — this is a disclosure, so the submit-side whitelist is not the backstop it reads as.
Why it matters after ADR-0106
ADR-0106 (#3682) just made the platform able to state one complete sentence: a field the caller cannot read does not exist for that caller, on any plane. This route is the remaining counterexample, and the caller here is anonymous. ADR-0106's own Context section is the argument: field names answer "what does this company record", picklist option sets are operational taxonomies, and a formula expression is pricing/scoring IP. A section-less public form is a plausible authoring state (an author creates the form and wires sections later), so this is not an exotic configuration.
Suggested disposition
The "zero sections means all fields" expansion is the whole finding — everything else about this route is a deliberate, declared publication. Options, roughly in order of preference:
- Make the expansion explicit rather than implicit. A form that declares no fields publishes no schema (
fields: {}), and an author who really wants the whole object says so with an explicit opt-in on the form. Publication should be a declaration, not the default that falls out of an empty set (AGENTS.md "Explicit composition over default magic"). - Or narrow the expansion to the fields the submit route would actually accept, so the disclosure surface and the write surface are the same set by construction rather than by coincidence.
Whichever is chosen, pin it: the allowed.size === 0 branch currently has no test asserting what it publishes.
Repro sketch
# a public FormView with `sections: []` (or omitted) targeting `account`
curl /api/v1/forms/<slug> | jq '.objectSchema.fields | keys'
# → every non-server-managed field of `account`, unauthenticated
Related: #3682 (ADR-0106 implementation), #3022 (server-managed anchors on the public form surface), ADR-0056 (public form grant).
Activity
os-zhuang commentedon Aug 8, 2026
Findings-round routing repair:
domain:cliadded. Routing only —findinggrade deliberately untouched this round.origin/main@bb7cb41):packages/rest/src/rest-server.ts:7063-7064— thePUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)skip followed byif (allowed.size === 0 || allowed.has(name)), i.e. the zero-sections fall-through the body describes.packages/rest→domain:cliper the domain table.record--prefixed flowtriggerTypesilently degrades the flow to manual — no lint, no runtime signal (spun out of #5957) #6637). Two concurrent grade producers on one pool is the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 failure shape, so this seat repaired routing only. Handoff note for that sweep: this card is a promotion candidate — an anonymous-caller disclosure on a shipped surface (GET /api/v1/forms/:slug), the remaining counterexample to ADR-0106's invariant, with a plausible authoring state as the trigger; on promotion it plausibly meets release-board ground ① (security hole on a shipped face). Both suggested dispositions in the body tighten the surface; the choice between explicit opt-in vs submit-set narrowing is the only fork.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 8, 2026
Findings round: promoted —
findingremoved,pm:queue+target:v17applied (with the existingdomain:cli). This completes a half-state: the maintainer-directed sweep's 10:23Z comment on this card announced exactly this disposition (promote,domain:cli,target:v17), but the labels never landed — the card still readfinding, invisible to the queue view. Label discipline says comment and labels land as a pair, so this round finishes the pair rather than re-litigating the grade.origin/main@cfeb9a0:packages/rest/src/rest-server.ts:7063-7064— thePUBLIC_FORM_SERVER_MANAGED_FIELDSskip followed byif (allowed.size === 0 || allowed.has(name))fall-through is still live, and nothing between :7064 and thesafeFormsections filter narrowsobjectSchema.fields. The zero-sections anonymous disclosure stands.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
认领本卡(dev seat)。
d2cb1100-cddb-5b45-9352-e7100364421eclaude/issue-6601-public-form-schema-narrowing../objectstack-6601(PD Migrate documentation site to Fumadocs with monorepo structure and shared content #11)packages/rest/src/rest-server.ts+ 测试 + changeset。不动packages/spec,不碰 finding:/meta/_draftsserves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered #6599 / finding: after ADR-0106, a restricted caller's GET → edit → PUT of an object schema DELETES the fields that were masked out of their read #6603。开工前已核对:
origin/main@970bb4c8上没有在飞 PR 占用rest-server.ts(最近两位持有者 #6535 / #6714 均已合并)。Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
ACCEPT — PR #6933 复核通过,已翻 ready + 挂 auto-merge。
先记我自己的错
我在派发令里写:「零 sections 分支没有任何测试断言它发出什么 —— issue 明说了,不许留它不钉」。这是错的,而且错在我把卡片的子断言当既定事实照抄进了派发令。
public-form-routes.test.ts:145-151有断言,断的是['email','status','subject']。那条分支不是没被钉 —— 它是被从 #3022 锚点字段的角度钉成了「正确」,顺带把「全字段展开」冻结成了预期行为。dev 翻转了那条断言,并在原处留注释说明没有丢覆盖(锚点属性在提交路由上另有自己的零 sections 钉子,那里它确实起作用)。looksLikeInternalErrorLeak会脱敏安全消息、#6878 的「套件里没有任何用例传重复参数」),三次都由 dev 用测量纠正。本席已把口径改为:派发令里凡继承自卡片的断言一律标「须实测」,不以陈述句写出。测量闸救了这张卡
我在令里把「对齐提交面」列为倾向方向,但加了停机条件:先量提交面在零 sections 时是否更窄。dev 量了 —— 提交路由(
rest-server.ts:7259)是同形退化:零 sections 时它接受的键集是
["email","internal_margin","internal_tier","not_even_a_field","status","subject"]—— 「对象全部字段」的超集。对齐过去会逐字节 republish 本卡要删的东西。倾向方向是空的,回退分支才对。若没有那道闸,这张卡会以「已按 PM 倾向实现」的面貌落地,而实际什么都没修。
落地形态与证据
一行:
if (!allowed.has(name)) continue;。没声明字段就发fields: {}。没有新增任何可编写的键(无「发布整个对象」的显式开关 —— ADR-0049:没有拉力就不建能力);信封形状不变(objectSchema仍是{ name, label, fields },不会变null);PUBLIC_FORM_SERVER_MANAGED_FIELDS、提交路由、lookup 路由均未动。匿名实测(fixture 带 select 选项值与 formula 表达式):
反向验证:恢复
allowed.size === 0 ||→4 failed | 8 passed;两条 no-regression 钉子双向绿,并在测试文件与 PR 里都标注为「不构成本次修复的证据」。预测写在跑之前,三项(闸的走向、前后键集、红的家族)全中。changeset
minor判据正确(响应少了字段属 wire-visible),援引最近先例metadata-plane-fls-object-schema-masking.md(ADR-0106 掩码那次)。越界发现
not_even_a_field到达createData。本席已判入决策箱并给了强建议:本 PR 合入后,喂养这条宽写入面的读取面就没有了,留着它只剩「读侧收窄、写侧洞开」的不对称。FormViewSchema.groupsis declared as "alias to sections" but nothing folds it — every consumer reads onlysections#6926 ——FormViewSchema.groups自称 legacy alias 但无人折叠;三条公开表单路由只读sections,唯一处理者是 i18n 翻译遍历(这正是它 grep 起来像活的原因)。已标finding。Generated by Claude Code
fix(metadata-protocol): the `groups` → `sections` fold reaches the st…