Skip to content

finding: a section-less public form accepts ANY non-anchor field from an anonymous visitor (the write-side twin of #6601) #6920

Description

@os-project-manager

Found while implementing #6601 (the read-side disclosure on the same route pair). Filed rather than fixed: #6601's lane is the read surface, this is the write surface, and the current behaviour is deliberately pinned by an existing test, so changing it is its own decision.

What was measured

POST /api/v1/forms/:slug/submit (packages/rest/src/rest-server.ts:7236-7260) narrows the visitor-suppliable keys to the fields the matched FormView's sections declare — and then degenerates exactly the way #6601's read side did:

const allowedFields = new Set();
for (const section of match.form?.sections ?? []) { /* … collect … */ }

for (const [k, v] of Object.entries(rawBody)) {
  if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(k)) continue;
  if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
  if (allowedFields.size === 0 || allowedFields.has(k)) filteredData[k] = v;
}

Measured on origin/main @ 970bb4c8, driving the real registered handler with a public FormView whose sections is [], anonymously:

submit accepted = ["email","internal_margin","internal_tier","not_even_a_field","status","subject"]

Every key survived except the PUBLIC_FORM_SERVER_MANAGED_FIELDS anchors — including not_even_a_field, which is not a field of the target object at all. So the accepted set for a section-less public form is not "every field of the object"; it is every key the caller sends, minus the anchors and the three prototype keys.

Why it matters

#3022 closed the ownership-forging half of this fall-through (anchors can no longer be supplied) and pinned the remainder as intentional — public-form-routes.test.ts, zero declared sections: business fields fall through, anchors do NOT. What that pin leaves open is anonymous mass assignment of ordinary business columns: status, a workflow stage, an internal tier, an amount — anything the object declares and the form did not.

The trigger is the same plausible authoring mid-state #6601 turns on: an author creates the public form, wires its sections later. Between those two edits the form is anonymously writable across its whole object. publicFormGrant (ADR-0056) scopes the insert to create-on-that-object, so this is not a cross-object hole — it is an unbounded column surface on the one object the form targets.

After #6601 the two surfaces disagree in a way worth naming: a section-less form now publishes no schema and still accepts every key. #6601's dispatch ruling considered aligning read to write and rejected it precisely because this write set is not narrower than "everything" — which is this card.

Suggested disposition

Symmetry with #6601 is the obvious candidate: drop the allowedFields.size === 0 || limb so a form that declares no fields accepts no fields, and the submit answers a 400-class refusal naming the empty declaration rather than inserting a blank row. That makes "the form declares what it collects" one rule on both planes, with no new authorable key.

Two things a fixer must weigh, which is why this is not folded into #6601:

  1. It is a live pinned behaviour. The 安全:公开表单(publicFormGrant)提交绕过 owner_id 属主守卫 → 匿名可伪造属主 #3022 test above asserts the fall-through. Removing it re-judges that pin rather than adding to it.
  2. It changes a shipped success path into a refusal. Any deployment currently relying on a section-less public form to collect submissions starts getting errors. That is arguably correct (such a form cannot render either — after finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601 it publishes no schema) but it is a behaviour change with a release note, not a silent tightening.

Repro sketch

# a public FormView with `sections: []` (or omitted) targeting `ticket`
curl -X POST /api/v1/forms/SLUG/submit \
  -d '{"subject":"x","internal_tier":"strategic","not_even_a_field":"accepted"}'
# → 201; every key above reaches createData

Related: #6601 (read-side twin, same two lines apart), #3022 (server-managed anchors on this surface), ADR-0056 (public form grant).

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions