diff --git a/.changeset/public-form-server-managed-anchors.md b/.changeset/public-form-server-managed-anchors.md new file mode 100644 index 000000000..44af3d122 --- /dev/null +++ b/.changeset/public-form-server-managed-anchors.md @@ -0,0 +1,42 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/rest': patch +'@objectstack/spec': patch +--- + +fix(security): public-form submissions can no longer forge server-managed anchors (#3022) + +The anonymous public-form surface (ADR-0056 Option A, `POST /forms/:slug/submit`) +is authorized by the declaration-derived `publicFormGrant`, which short-circuits +the security middleware BEFORE every write gate (CRUD, FLS, the owner anchor +guard, the tenant CHECK). The only field-side defense was the route's +declared-field allow-list — and a FormView with zero declared section fields +fell back to merging the raw body wholesale, so an unauthenticated visitor +could `POST owner_id=` (or `organization_id`, audit columns, `id`) and +attach the record to another user or tenant — the #3004 insert-forge, with no +credentials at all. + +Server-managed anchors are now enforced on this surface at BOTH layers, from a +single shared definition (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`, new in +`@objectstack/spec/security`): + +- **Data layer (authoritative)** — the `publicFormGrant` branch in + `@objectstack/plugin-security` strips `id` / `owner_id` / `organization_id` / + `tenant_id` / audit columns / soft-delete state / `__search` from every row + of a granted insert (batch included) before admitting the write, so the + boundary holds no matter what any route lets through. Ownership stays NULL + for object hooks / the first-admin bootstrap to assign, as for other + anonymous-seeded rows. +- **Route layer** — the submit allow-list excludes the same set + unconditionally: an explicitly declared `owner_id` section field no longer + passes, and the zero-declared-sections fallback keeps its documented + all-fields behavior for business columns while refusing the managed set. + The resolve route (`GET /forms/:slug`) drops the managed fields from the + rendered sections and the embedded object schema so a form never collects a + value the submit refuses, and `GET /forms/:slug/lookup/:field` refuses a + `publicPicker` declared on a managed anchor (which would have opened + anonymous `sys_user` search through `owner_id`). + +Authenticated writes are unaffected — this is the anonymous-surface rule only; +`owner_id` transfer semantics for signed-in callers stay governed by the +transfer grant (#3004 / PR #3018). diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 15833ff1e..3781fcf2b 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -45,6 +45,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else. +System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022). + ### 3. Keep the object private once inside Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation. The public path only ever **inserts**; it never lists. diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index 677b3c847..12add1f56 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -120,6 +120,10 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'bulk-write-owner-scoping', summary: 'bulk (multi) update/delete are owner-scoped on OWD-private objects, not just single-id writes (#2982)', state: 'enforced', enforcement: 'objectql/engine.ts seeds opCtx.ast for no-single-id update/delete BEFORE the middleware chain and hands the composed AST to driver.updateMany/deleteMany, so plugin-sharing buildWriteFilter (owner-match + shares) and plugin-security RLS write filters actually bind bulk writes', proof: 'owner-anchor-and-bulk-writes.dogfood.test.ts' }, + { id: 'public-form-managed-anchors', summary: 'anonymous public-form submit cannot supply server-managed anchors (owner_id / organization_id / audit / id) — #3022', state: 'enforced', + enforcement: 'spec/security/public-form.ts PUBLIC_FORM_SERVER_MANAGED_FIELDS shared by rest/rest-server.ts form routes (allow-list + schema/section/lookup exposure) AND plugin-security publicFormGrant branch (strips every insert row before the grant admits the write — the data-layer boundary the grant otherwise bypasses; complements the #3004 step 3.5 guard, which the grant short-circuits)', + proof: 'showcase-public-form.dogfood.test.ts', + note: 'Proof file carries the forged owner_id/organization_id submit case; the route-level matrix is covered unit-side in rest public-form-routes.test.ts + plugin-security security-plugin.test.ts (publicFormGrant strip suite).' }, { id: 'record-share', summary: 'manual record shares (sys_record_share)', state: 'enforced', enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter/canEdit' }, { id: 'sharing-rules', summary: 'criteria/owner sharing rules', state: 'enforced', diff --git a/packages/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/dogfood/test/showcase-public-form.dogfood.test.ts index 97e45800e..91a3c8b23 100644 --- a/packages/dogfood/test/showcase-public-form.dogfood.test.ts +++ b/packages/dogfood/test/showcase-public-form.dogfood.test.ts @@ -63,6 +63,28 @@ describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { expect(body.record.source).toBe('web'); }); + it('a forged owner_id / organization_id never lands on the row (#3022)', async () => { + const r = await stack.api('/forms/contact-us/submit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Mallory', + email: 'mallory@example.com', + message: 'Attach this record to the victim.', + owner_id: 'usr_victim', // ← ownership forge (#3004-class, no credentials) + organization_id: 'org_victim', // ← cross-tenant landing attempt + created_by: 'usr_victim', + }), + }); + expect(r.status, 'the submit itself still succeeds — the anchors are stripped, not fatal').toBe(201); + const body = (await r.json()) as { record: Record }; + expect(body.record.name).toBe('Mallory'); + // The anchors are server-managed on this surface: never the forged values. + expect(body.record.owner_id ?? null, 'anonymous submission must not forge ownership').not.toBe('usr_victim'); + expect(body.record.organization_id ?? null, 'anonymous submission must not land cross-tenant').not.toBe('org_victim'); + expect(body.record.created_by ?? null).not.toBe('usr_victim'); + }); + it('the public grant is create + read-back ONLY — anonymous cannot list inquiries', async () => { const r = await stack.api('/data/showcase_inquiry'); expect(r.status, 'general anonymous read must NOT be opened by the form grant').not.toBe(200); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 0d44bd7b6..8faa2946a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -430,6 +430,83 @@ describe('SecurityPlugin', () => { expect(opCtx.ast.where).toBeUndefined(); }); + // ── publicFormGrant — server-managed anchor stripping (#3022) ─────────── + // The ADR-0056 Option A grant short-circuits the middleware BEFORE every + // write gate (FLS 2.5, owner anchor 3.5, tenant CHECK), so the grant branch + // itself must force the system-managed anchors: an anonymous public-form + // insert can never forge `owner_id` / `organization_id` / audit columns, + // no matter what the route-side field allow-list let through. + describe('publicFormGrant — server-managed anchor stripping (#3022)', () => { + const anonFormCtx = { publicFormGrant: { object: 'task' }, permissions: ['guest_portal'], anonymous: true }; + + const boot = async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + return harness; + }; + + it('strips forged owner_id / organization_id / audit anchors from an insert', async () => { + const harness = await boot(); + const opCtx: any = { + object: 'task', operation: 'insert', + data: { + name: 'Ada', id: 'rec_forged', owner_id: 'usr_victim', organization_id: 'org_victim', + created_by: 'usr_victim', updated_by: 'usr_victim', + created_at: '2020-01-01T00:00:00Z', updated_at: '2020-01-01T00:00:00Z', + }, + context: anonFormCtx, + }; + await harness.run(opCtx); + // Business fields survive; every server-managed anchor is gone + // (ownership stays unset for hooks / first-admin bootstrap to assign). + expect(opCtx.data).toEqual({ name: 'Ada' }); + }); + + it('strips anchors from EVERY row of a batch insert', async () => { + const harness = await boot(); + const opCtx: any = { + object: 'task', operation: 'insert', + data: [ + { name: 'A', owner_id: 'usr_victim' }, + { name: 'B', organization_id: 'org_victim' }, + { name: 'C' }, + ], + context: anonFormCtx, + }; + await harness.run(opCtx); + expect(opCtx.data).toEqual([{ name: 'A' }, { name: 'B' }, { name: 'C' }]); + }); + + it('a clean insert passes through untouched', async () => { + const harness = await boot(); + const opCtx: any = { + object: 'task', operation: 'insert', + data: { name: 'Ada', email: 'ada@example.com' }, + context: anonFormCtx, + }; + await harness.run(opCtx); + expect(opCtx.data).toEqual({ name: 'Ada', email: 'ada@example.com' }); + }); + + it('the grant still refuses non-granted operations and foreign objects', async () => { + const harness = await boot(); + await expect( + harness.run({ + object: 'task', operation: 'update', data: { owner_id: 'usr_victim' }, + context: anonFormCtx, + }), + ).rejects.toThrow(/public-form grant permits only create\/read-back/); + await expect( + harness.run({ + object: 'sys_user', operation: 'insert', data: { name: 'x' }, + context: anonFormCtx, + }), + ).rejects.toThrow(/public-form grant permits only create\/read-back/); + }); + }); + // ── Row-level WRITE authorization (pre-image check, #1985) ────────────── // A by-id update/delete never builds an RLS `where`, so the owner/tenant // predicate must be enforced by re-reading the target row before mutating. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 981b33230..d886a394e 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; -import { describeHighPrivilegeBits, describeAnchorForbiddenBits } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; @@ -569,7 +569,39 @@ export class SecurityPlugin implements Plugin { const allowed = opCtx.object === grantObject && ['insert', 'find', 'findOne', 'count'].includes(opCtx.operation); - if (allowed) return next(); + if (allowed) { + // [#3022] The grant bypasses every downstream write gate (FLS 2.5, + // owner anchor 3.5, tenant CHECK) — so the system-managed anchors + // must be forced HERE, before the write is admitted. An anonymous + // submission has no principal to backfill from and no conceivable + // transfer authorization, so a supplied `owner_id` / + // `organization_id` / audit column is stripped from every row (the + // route-side field allow-list is the first net; this is the + // data-layer boundary that holds even if a FormView declares — or + // a zero-section form falls open to — one of these columns). + // Ownership stays NULL for object hooks / the first-admin + // bootstrap to assign, exactly like other anonymous-seeded rows. + if (opCtx.operation === 'insert' && opCtx.data && typeof opCtx.data === 'object') { + const rows = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]; + const stripped = new Set(); + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row)) continue; + for (const field of PUBLIC_FORM_SERVER_MANAGED_FIELDS) { + if (Object.prototype.hasOwnProperty.call(row, field)) { + delete (row as Record)[field]; + stripped.add(field); + } + } + } + if (stripped.size > 0) { + ctx.logger.warn( + `[security] public-form insert on '${grantObject}' supplied server-managed ` + + `field(s) [${[...stripped].join(', ')}] — stripped (#3022)`, + ); + } + } + return next(); + } throw new PermissionDeniedError( `[Security] Access denied: public-form grant permits only create/read-back on '${grantObject}', ` + `not '${opCtx.operation}' on '${opCtx.object}'`, diff --git a/packages/rest/src/public-form-routes.test.ts b/packages/rest/src/public-form-routes.test.ts new file mode 100644 index 000000000..79b766382 --- /dev/null +++ b/packages/rest/src/public-form-routes.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#3022] Anonymous public-form routes (ADR-0056 Option A) — server-managed +// anchor enforcement. The submit route must never accept `owner_id` / +// `organization_id` / audit columns from a visitor: not via an explicit +// section declaration, and not via the zero-declared-sections fallback that +// previously merged the raw body wholesale (the insert-forge of #3004, but +// with no credentials at all). The resolve/lookup routes must agree with the +// submit boundary so a form never collects what the submit refuses. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.end = vi.fn(() => res); + return res; +} + +/** A public FormView in the flattened registered shape (one item per view). */ +function formView(sections: any[]) { + return { + name: 'ticket_form', + object: 'ticket', + viewKind: 'form', + config: { + data: { object: 'ticket' }, + sections, + sharing: { allowAnonymous: true, publicLink: '/forms/test' }, + }, + }; +} + +const ticketObject = { + name: 'ticket', + label: 'Ticket', + fields: { + id: { type: 'text' }, + subject: { type: 'text', label: 'Subject' }, + email: { type: 'text', label: 'Email' }, + status: { type: 'select', label: 'Status' }, + owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' }, + organization_id: { type: 'lookup', reference: 'sys_organization', label: 'Organization' }, + created_at: { type: 'datetime', label: 'Created At' }, + created_by: { type: 'lookup', reference: 'sys_user', label: 'Created By' }, + }, +}; + +function buildServer(sections: any[]) { + const createData = vi.fn().mockResolvedValue({ object: 'ticket', id: 'rec_1', record: {} }); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'view') return [formView(sections)]; + if (type === 'object') return [ticketObject]; + return []; + }), + createData, + }; + const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); + rest.registerRoutes(); + const find = (method: string, suffix: string) => + rest.getRoutes().find((r) => r.method === method && r.path.endsWith(suffix))!; + return { + createData, + resolve: find('GET', '/forms/:slug'), + submit: find('POST', '/forms/:slug/submit'), + lookup: find('GET', '/forms/:slug/lookup/:field'), + }; +} + +const FORGED = { + id: 'rec_forged', + owner_id: 'usr_victim', + organization_id: 'org_victim', + created_by: 'usr_victim', + created_at: '2020-01-01T00:00:00Z', +}; + +describe('POST /forms/:slug/submit — server-managed anchors (#3022)', () => { + it('declared-field whitelist: undeclared and server-managed fields are dropped', async () => { + // The form even (mis)declares `owner_id` — the anchor still may not pass. + const { submit, createData } = buildServer([{ fields: ['subject', 'email', { field: 'owner_id' }] }]); + const res = mockRes(); + await submit.handler( + { params: { slug: 'test' }, body: { subject: 'Help', email: 'a@b.c', status: 'closed', ...FORGED } } as any, + res, + ); + expect(res.statusCode).toBe(201); + expect(createData).toHaveBeenCalledTimes(1); + expect(createData.mock.calls[0][0].data).toEqual({ subject: 'Help', email: 'a@b.c' }); + }); + + it('zero declared sections: business fields fall through, anchors do NOT', async () => { + // The all-fields fallback previously merged the raw body wholesale — + // the unauthenticated insert-forge of the issue. Business fields keep + // the documented fall-through; every server-managed anchor is excluded. + const { submit, createData } = buildServer([]); + const res = mockRes(); + await submit.handler( + { params: { slug: 'test' }, body: { subject: 'Help', status: 'open', ...FORGED } } as any, + res, + ); + expect(res.statusCode).toBe(201); + expect(createData.mock.calls[0][0].data).toEqual({ subject: 'Help', status: 'open' }); + }); + + it('a __proto__ body key cannot smuggle inherited anchors past the filter', async () => { + const { submit, createData } = buildServer([]); + const res = mockRes(); + // JSON.parse produces `__proto__` as an OWN key; a naive `obj[k] = v` + // assignment would replace the payload's prototype with this object and + // `data.owner_id` would resolve to the forged value via inheritance. + const body = JSON.parse('{"subject": "Help", "__proto__": {"owner_id": "usr_victim"}}'); + await submit.handler({ params: { slug: 'test' }, body } as any, res); + expect(res.statusCode).toBe(201); + const sent = createData.mock.calls[0][0].data; + expect(sent).toEqual({ subject: 'Help' }); + expect((sent as any).owner_id, 'no inherited owner_id either').toBeUndefined(); + }); + + it('the insert is scoped by the declaration-derived publicFormGrant', async () => { + const { submit, createData } = buildServer([{ fields: ['subject'] }]); + await submit.handler({ params: { slug: 'test' }, body: { subject: 'Hi' } } as any, mockRes()); + expect(createData.mock.calls[0][0].context).toMatchObject({ + publicFormGrant: { object: 'ticket' }, + anonymous: true, + }); + }); +}); + +describe('GET /forms/:slug — schema/sections agree with the submit boundary (#3022)', () => { + it('zero declared sections: the all-fields schema expansion excludes managed anchors', async () => { + const { resolve } = buildServer([]); + const res = mockRes(); + await resolve.handler({ params: { slug: 'test' }, headers: {} } as any, res); + expect(res.statusCode).toBe(200); + expect(Object.keys(res.body.objectSchema.fields).sort()).toEqual(['email', 'status', 'subject']); + }); + + it('a declared owner_id is dropped from the rendered sections and schema', async () => { + const { resolve } = buildServer([{ fields: ['subject', { field: 'owner_id' }] }]); + const res = mockRes(); + await resolve.handler({ params: { slug: 'test' }, headers: {} } as any, res); + expect(res.statusCode).toBe(200); + expect(Object.keys(res.body.objectSchema.fields)).toEqual(['subject']); + const rendered = res.body.form.sections.flatMap((s: any) => + (s.fields ?? []).map((f: any) => (typeof f === 'string' ? f : f.field))); + expect(rendered).toEqual(['subject']); + }); +}); + +describe('GET /forms/:slug/lookup/:field — no picker on managed anchors (#3022)', () => { + it('refuses a publicPicker declared on owner_id (would open anonymous sys_user search)', async () => { + const { lookup } = buildServer([ + { fields: [{ field: 'owner_id', publicPicker: { displayFields: ['name'] } }] }, + ]); + const res = mockRes(); + await lookup.handler({ params: { slug: 'test', field: 'owner_id' }, query: {} } as any, res); + expect(res.statusCode).toBe(403); + expect(res.body.code).toBe('LOOKUP_NOT_PUBLIC'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3c8ea033c..f4c08d442 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8,6 +8,7 @@ import { isMcpServerEnabled } from '@objectstack/types'; import { RouteManager } from './route-manager.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; +import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; /** * The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462 @@ -4499,6 +4500,12 @@ export class RestServer { } const fields: Record = {}; for (const [name, def] of Object.entries(obj.fields)) { + // [#3022] Server-managed anchors are never + // renderable/writable on the anonymous form + // surface — the submit route refuses them, so + // don't advertise them here (declared or via + // the zero-sections all-fields expansion). + if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) continue; if (allowed.size === 0 || allowed.has(name)) { fields[name] = def; } @@ -4536,6 +4543,11 @@ export class RestServer { const safeForm = (() => { if (!match.form || !Array.isArray(match.form.sections)) return match.form; const allow = (name: string, cfg: any): boolean => { + // [#3022] A declared server-managed anchor (e.g. a + // FormView listing `owner_id`) is a spec mistake — + // drop it from the rendered sections so the form + // never collects a value the submit route refuses. + if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) return false; const def = objectSchema?.fields?.[name]; const t = def?.type; // `user` is a lookup specialized to sys_user — same risk as a @@ -4613,14 +4625,23 @@ export class RestServer { else if (f?.field) allowedFields.add(f.field); } } + // [#3022] System-managed anchors (owner_id, organization_id, + // audit columns, id, …) are NEVER client-suppliable on this + // anonymous surface — not via an explicit section declaration, + // and not via the zero-declared-sections fallback below (which + // otherwise passes the raw body through and previously let a + // visitor forge record ownership). The SecurityPlugin's + // publicFormGrant branch strips the same set at the data layer, + // so this filter and the engine boundary cannot drift. const rawBody = (req.body && typeof req.body === 'object') ? req.body : {}; const filteredData: Record = {}; - if (allowedFields.size > 0) { - for (const [k, v] of Object.entries(rawBody)) { - if (allowedFields.has(k)) filteredData[k] = v; - } - } else { - Object.assign(filteredData, rawBody); + for (const [k, v] of Object.entries(rawBody)) { + if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(k)) continue; + // JSON.parse yields `__proto__` as an OWN key; assigning it + // here would REPLACE filteredData's prototype and smuggle + // inherited anchors past every own-property check downstream. + if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; + if (allowedFields.size === 0 || allowedFields.has(k)) filteredData[k] = v; } // ADR-0056 (Option A): authorization DERIVED from the declared @@ -4692,16 +4713,22 @@ export class RestServer { // `publicPicker` block. Without it the lookup is // considered private — return 403, not 404, so a // misconfigured form is loud rather than silent. + // [#3022] Server-managed anchors are unwritable on this + // surface (the submit route strips them), so a picker on + // one (e.g. a declared `owner_id` + `publicPicker`, which + // would open anonymous sys_user search) is refused outright. let fieldCfg: any = null; - for (const sec of match.form?.sections ?? []) { - for (const f of sec?.fields ?? []) { - const name = typeof f === 'string' ? f : f?.field; - if (name === fieldName) { - fieldCfg = typeof f === 'string' ? {} : f; - break; + if (!PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(fieldName)) { + for (const sec of match.form?.sections ?? []) { + for (const f of sec?.fields ?? []) { + const name = typeof f === 'string' ? f : f?.field; + if (name === fieldName) { + fieldCfg = typeof f === 'string' ? {} : f; + break; + } } + if (fieldCfg) break; } - if (fieldCfg) break; } const picker = fieldCfg?.publicPicker; if (!picker) { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index dd3f84e32..232af3f69 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3978,6 +3978,7 @@ "OwnerSharingRuleSchema (const)", "PLATFORM_CAPABILITIES (const)", "PLATFORM_CAPABILITY_NAMES (const)", + "PUBLIC_FORM_SERVER_MANAGED_FIELDS (const)", "PermissionSet (type)", "PermissionSetInput (type)", "PermissionSetSchema (const)", diff --git a/packages/spec/src/security/index.ts b/packages/spec/src/security/index.ts index 859027c2f..8a0c6297d 100644 --- a/packages/spec/src/security/index.ts +++ b/packages/spec/src/security/index.ts @@ -14,6 +14,7 @@ export * from './permission.zod'; export * from './permission.form'; export * from './capabilities'; export * from './high-privilege'; +export * from './public-form'; export * from './explain.zod'; export * from './sharing.zod'; export * from './territory.zod'; diff --git a/packages/spec/src/security/public-form.ts b/packages/spec/src/security/public-form.ts new file mode 100644 index 000000000..b340ab489 --- /dev/null +++ b/packages/spec/src/security/public-form.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0056 Option A / #3022] Server-managed columns on the anonymous + * PUBLIC-FORM write surface. + * + * A public form submission (`POST /forms/:slug/submit`) is an UNAUTHENTICATED + * internet-facing insert authorized by the declaration-derived + * `publicFormGrant`. That grant short-circuits the security middleware + * (CRUD/FLS/owner/tenant gates), and the static-`readonly` strip (#2948) only + * covers UPDATE — so nothing downstream guards these columns. They are + * therefore server-managed on this surface: never client-suppliable, + * regardless of what the FormView declares. + * + * Shared by the REST form routes (`@objectstack/rest` — the writable-field + * allow-list and the resolved form/schema exposure) and the data-layer grant + * branch (`@objectstack/plugin-security` — strips them from every insert row + * before the grant admits the write), so the route filter and the engine + * boundary can never drift apart. + * + * Scope: this is the ANONYMOUS-surface rule only. Authenticated writes keep + * their existing semantics (e.g. insert may seed `readonly` columns for + * imports; `owner_id` transfers are governed by the transfer grant). + */ +export const PUBLIC_FORM_SERVER_MANAGED_FIELDS: ReadonlySet = new Set([ + // Row identity — a visitor-chosen primary key invites collision/squatting. + 'id', + // Ownership anchor (OWD/RLS owner scoping keys off it; #3004-class forge). + 'owner_id', + // Tenant anchors — a forged value lands the row in another tenant. + 'organization_id', + 'tenant_id', + // Audit provenance + timestamps. + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + // Soft-delete state. + 'is_deleted', + 'deleted_at', + // Hidden search-normalization companion column (#2486). + '__search', +]);