Skip to content

finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601

Closed

Description

@baozhoutao

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

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Findings-round routing repair: domain:cli added. Routing only — finding grade deliberately untouched this round.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Findings round: promoted — finding removed, pm:queue + target:v17 applied (with the existing domain: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 read finding, 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.

    • Independent premise re-check on origin/main @ cfeb9a0: packages/rest/src/rest-server.ts:7063-7064 — the PUBLIC_FORM_SERVER_MANAGED_FIELDS skip followed by if (allowed.size === 0 || allowed.has(name)) fall-through is still live, and nothing between :7064 and the safeForm sections filter narrows objectSchema.fields. The zero-sections anonymous disclosure stands.
    • Grade rationale (concurring with the sweep): anonymous-caller disclosure of full field definitions incl. picklist option sets on a shipped surface, reachable from a plausible authoring state (form created, sections wired later) — release board ground ① (security-hole class). Both dispositions in the body tighten the surface; explicit-opt-in vs submit-set narrowing is a PR-reviewable fork, with the body's first option as default.
    • Dedup: unchanged from the 10:27Z routing comment — 实现 ADR-0106:元数据面 FLS——object schema 按调用者掩码(#3661 ③ 落地) #3682 scoped this out at the origin; no other open card covers the forms-route disclosure.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    认领本卡(dev seat)。

    开工前已核对:origin/main@970bb4c8 上没有在飞 PR 占用 rest-server.ts(最近两位持有者 #6535 / #6714 均已合并)。


    Generated by Claude Code

  4. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #6933 复核通过,已翻 ready + 挂 auto-merge。

    先记我自己的错

    我在派发令里写:「零 sections 分支没有任何测试断言它发出什么 —— issue 明说了,不许留它不钉」。这是错的,而且错在我把卡片的子断言当既定事实照抄进了派发令。

    public-form-routes.test.ts:145-151 有断言,断的是 ['email','status','subject']。那条分支不是没被钉 —— 它是被从 #3022 锚点字段的角度钉成了「正确」,顺带把「全字段展开」冻结成了预期行为。dev 翻转了那条断言,并在原处留注释说明没有丢覆盖(锚点属性在提交路由上另有自己的零 sections 钉子,那里它确实起作用)。

    ⚠️ 这是本班次第三次同形错误(前两次:#6649 的 looksLikeInternalErrorLeak 会脱敏安全消息、#6878 的「套件里没有任何用例传重复参数」),三次都由 dev 用测量纠正。本席已把口径改为:派发令里凡继承自卡片的断言一律标「须实测」,不以陈述句写出。

    测量闸救了这张卡

    我在令里把「对齐提交面」列为倾向方向,但加了停机条件:先量提交面在零 sections 时是否更窄。dev 量了 —— 提交路由(rest-server.ts:7259)是同形退化:

    allowedFields.size === 0 || allowedFields.has(k)
    

    零 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 表达式):

    BEFORE = ["email","internal_margin","internal_tier","status","subject"]
             internal_tier.options   = [{"label":"Strategic",…},{"label":"Churn Risk",…}]
             internal_margin.formula = "(amount - cost) / amount * 0.87"
    AFTER  = []      options = null      formula = null
    BEFORE/AFTER 已声明 sections 的用例 = ["email","subject"](两个方向逐字相同)
    

    反向验证:恢复 allowed.size === 0 || → 4 failed | 8 passed;两条 no-regression 钉子双向绿,并在测试文件与 PR 里都标注为「不构成本次修复的证据」。预测写在跑之前,三项(闸的走向、前后键集、红的家族)全中。

    changeset minor 判据正确(响应少了字段属 wire-visible),援引最近先例 metadata-plane-fls-object-schema-masking.md(ADR-0106 掩码那次)。

    越界发现


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions