Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/public-form-server-managed-anchors.md
Original file line number Diff line number Diff line change
@@ -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=<victim>` (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).
2 changes: 2 additions & 0 deletions content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 22 additions & 0 deletions packages/dogfood/test/showcase-public-form.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
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);
Expand Down
77 changes: 77 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 34 additions & 2 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
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<string, unknown>)[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}'`,
Expand Down
Loading