|
| 1 | +--- |
| 2 | +'@objectstack/plugin-security': patch |
| 3 | +'@objectstack/rest': patch |
| 4 | +'@objectstack/spec': patch |
| 5 | +--- |
| 6 | + |
| 7 | +fix(security): public-form submissions can no longer forge server-managed anchors (#3022) |
| 8 | + |
| 9 | +The anonymous public-form surface (ADR-0056 Option A, `POST /forms/:slug/submit`) |
| 10 | +is authorized by the declaration-derived `publicFormGrant`, which short-circuits |
| 11 | +the security middleware BEFORE every write gate (CRUD, FLS, the owner anchor |
| 12 | +guard, the tenant CHECK). The only field-side defense was the route's |
| 13 | +declared-field allow-list — and a FormView with zero declared section fields |
| 14 | +fell back to merging the raw body wholesale, so an unauthenticated visitor |
| 15 | +could `POST owner_id=<victim>` (or `organization_id`, audit columns, `id`) and |
| 16 | +attach the record to another user or tenant — the #3004 insert-forge, with no |
| 17 | +credentials at all. |
| 18 | + |
| 19 | +Server-managed anchors are now enforced on this surface at BOTH layers, from a |
| 20 | +single shared definition (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`, new in |
| 21 | +`@objectstack/spec/security`): |
| 22 | + |
| 23 | +- **Data layer (authoritative)** — the `publicFormGrant` branch in |
| 24 | + `@objectstack/plugin-security` strips `id` / `owner_id` / `organization_id` / |
| 25 | + `tenant_id` / audit columns / soft-delete state / `__search` from every row |
| 26 | + of a granted insert (batch included) before admitting the write, so the |
| 27 | + boundary holds no matter what any route lets through. Ownership stays NULL |
| 28 | + for object hooks / the first-admin bootstrap to assign, as for other |
| 29 | + anonymous-seeded rows. |
| 30 | +- **Route layer** — the submit allow-list excludes the same set |
| 31 | + unconditionally: an explicitly declared `owner_id` section field no longer |
| 32 | + passes, and the zero-declared-sections fallback keeps its documented |
| 33 | + all-fields behavior for business columns while refusing the managed set. |
| 34 | + The resolve route (`GET /forms/:slug`) drops the managed fields from the |
| 35 | + rendered sections and the embedded object schema so a form never collects a |
| 36 | + value the submit refuses, and `GET /forms/:slug/lookup/:field` refuses a |
| 37 | + `publicPicker` declared on a managed anchor (which would have opened |
| 38 | + anonymous `sys_user` search through `owner_id`). |
| 39 | + |
| 40 | +Authenticated writes are unaffected — this is the anonymous-surface rule only; |
| 41 | +`owner_id` transfer semantics for signed-in callers stay governed by the |
| 42 | +transfer grant (#3004 / PR #3018). |
0 commit comments