Skip to content
Merged
24 changes: 24 additions & 0 deletions .changeset/17852-record-proto-key-preparse-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
---

**BREAKING** for authored metadata — `ObjectSchema.fields` refuses a key named `__proto__`, `constructor` or `prototype`, and `AssignmentConfigSchema.assignments` (the `assignment` flow node's variable map) refuses a key named `__proto__` — both refused with a named, located error at parse time, rather than silently accepted and then silently mishandled (objectstack#17852, objectstack#18847).

## Why

zod's `z.record()` skips a `__proto__` own key entirely, above its own key schema — the record parser's `if (key === "__proto__") continue;` runs before `def.keyType._zod.run`, so no key grammar (a regex, `.refine()`, `.superRefine()`, even a key schema that rejects every string) can ever see that key. A document whose `fields` (or `assignments`) carried a `__proto__` own key — which `JSON.parse` produces routinely — used to parse as SUCCESS with that key silently missing from the output: the validator accepted a document and handed back a *different* document. `os build` writes the release artifact from that returned document, so the failure shape is success, silent, and irreversible into the shipped artifact.

Two independent mechanisms close this, one per name class, because they are not reachable the same way:

- `__proto__` is refused by a **pre-parse guard** that reads the raw input's own keys before the record ever parses, at both `ObjectSchema.fields` and `AssignmentConfigSchema.assignments`.
- `constructor` and `prototype` — which, unlike `__proto__`, DO reach the key schema unskipped — are refused by `ObjectSchema.fields`' own key grammar (they were ordinary lowercase words its regex already admitted). They are **not** refused at `AssignmentConfigSchema.assignments`: that slot's key type carries no grammar at all (`z.string().min(1)`), both names are legal flow-VARIABLE names measured to survive parse intact today, and no ruling narrows that slot's accept set for them — only its `__proto__` half moves.

Measured: zero authored use of any of the three names as a `fields` key or an `assignments` variable name, across this repo, `examples/` and `objectui`.

## Known gap, left open on purpose

The guard runs at parse time only. It does not project into the published JSON Schema (`packages/spec/json-schema/**`) — the general gap that closes is tracked separately (objectstack#18670) and stays open after this change.

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) zero authored use of `__proto__`, `constructor` or `prototype` as a `fields` key or an `assignments` variable name across this repo, examples/ and objectui — nobody has anything to rewrite, so there is no prescription to give. -->
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@ Metadata query with filtering, sorting, and pagination
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const result = ApiMethod.parse(data);
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ Create a new object
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
Expand Down Expand Up @@ -611,7 +611,7 @@ Create a new object
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
Expand Down
51 changes: 30 additions & 21 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 204,
"droppedRefinementSites": 560,
"droppedRefinementSites": 569,
"refinementSitesThatDidProject": 357,
"refinementSitesWithNoJsonFormToCompare": 9
},
Expand Down Expand Up @@ -66,8 +66,9 @@
"manifest.objectExtensions.element",
"manifest.objects.element",
"manifest.objects.element.fieldGroups",
"manifest.objects.element.fields.valueType",
"manifest.objects.element.fields.valueType.currencyConfig",
"manifest.objects.element.fields.out.keyType",
"manifest.objects.element.fields.out.valueType",
"manifest.objects.element.fields.out.valueType.currencyConfig",
"manifest.objects.element.lifecycle",
"manifest.pages.element",
"manifest.pages.element.slots.header.options[0].in.type",
Expand Down Expand Up @@ -157,8 +158,9 @@
"data.options[1].manifest.objectExtensions.element",
"data.options[1].manifest.objects.element",
"data.options[1].manifest.objects.element.fieldGroups",
"data.options[1].manifest.objects.element.fields.valueType",
"data.options[1].manifest.objects.element.fields.valueType.currencyConfig",
"data.options[1].manifest.objects.element.fields.out.keyType",
"data.options[1].manifest.objects.element.fields.out.valueType",
"data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
"data.options[1].manifest.objects.element.lifecycle",
"data.options[1].manifest.pages.element",
"data.options[1].manifest.pages.element.slots.header.options[0].in.type",
Expand Down Expand Up @@ -238,8 +240,9 @@
"options[1].manifest.objectExtensions.element",
"options[1].manifest.objects.element",
"options[1].manifest.objects.element.fieldGroups",
"options[1].manifest.objects.element.fields.valueType",
"options[1].manifest.objects.element.fields.valueType.currencyConfig",
"options[1].manifest.objects.element.fields.out.keyType",
"options[1].manifest.objects.element.fields.out.valueType",
"options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
"options[1].manifest.objects.element.lifecycle",
"options[1].manifest.pages.element",
"options[1].manifest.pages.element.slots.header.options[0].in.type",
Expand Down Expand Up @@ -280,8 +283,9 @@
"data.packages.element.options[1].manifest.objectExtensions.element",
"data.packages.element.options[1].manifest.objects.element",
"data.packages.element.options[1].manifest.objects.element.fieldGroups",
"data.packages.element.options[1].manifest.objects.element.fields.valueType",
"data.packages.element.options[1].manifest.objects.element.fields.valueType.currencyConfig",
"data.packages.element.options[1].manifest.objects.element.fields.out.keyType",
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType",
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
"data.packages.element.options[1].manifest.objects.element.lifecycle",
"data.packages.element.options[1].manifest.pages.element",
"data.packages.element.options[1].manifest.permissions.element.objects.valueType.out",
Expand Down Expand Up @@ -319,9 +323,10 @@
"data.actions.element.in",
"data.actions.element.in.params.element.in",
"data.fieldGroups",
"data.fields.valueType",
"data.fields.valueType.currencyConfig",
"data.fields.valueType.relatedListFilter.lazy",
"data.fields.out.keyType",
"data.fields.out.valueType",
"data.fields.out.valueType.currencyConfig",
"data.fields.out.valueType.relatedListFilter.lazy",
"data.lifecycle",
"data.listViews.valueType",
"data.listViews.valueType.bulkActionDefs.element",
Expand Down Expand Up @@ -378,7 +383,7 @@
},
"automation/AssignmentConfig": {
"sites": [
"assignments.valueType"
"assignments.out.valueType"
]
},
"automation/AssignmentValue": {
Expand Down Expand Up @@ -678,9 +683,10 @@
"actions.element.in",
"actions.element.in.params.element.in",
"fieldGroups",
"fields.valueType",
"fields.valueType.currencyConfig",
"fields.valueType.relatedListFilter.lazy",
"fields.out.keyType",
"fields.out.valueType",
"fields.out.valueType.currencyConfig",
"fields.out.valueType.relatedListFilter.lazy",
"lifecycle",
"listViews.valueType",
"listViews.valueType.bulkActionDefs.element",
Expand Down Expand Up @@ -948,7 +954,8 @@
"operations.element.options[3].object.actions.element.in",
"operations.element.options[3].object.actions.element.in.params.element.in",
"operations.element.options[3].object.fieldGroups",
"operations.element.options[3].object.fields.valueType",
"operations.element.options[3].object.fields.out.keyType",
"operations.element.options[3].object.fields.out.valueType",
"operations.element.options[3].object.lifecycle",
"operations.element.options[3].object.listViews.valueType",
"operations.element.options[3].object.listViews.valueType.bulkActionDefs.element",
Expand All @@ -965,9 +972,10 @@
"object.actions.element.in",
"object.actions.element.in.params.element.in",
"object.fieldGroups",
"object.fields.valueType",
"object.fields.valueType.currencyConfig",
"object.fields.valueType.relatedListFilter.lazy",
"object.fields.out.keyType",
"object.fields.out.valueType",
"object.fields.out.valueType.currencyConfig",
"object.fields.out.valueType.relatedListFilter.lazy",
"object.lifecycle",
"object.listViews.valueType",
"object.listViews.valueType.bulkActionDefs.element",
Expand Down Expand Up @@ -1007,7 +1015,8 @@
"options[3].object.actions.element.in",
"options[3].object.actions.element.in.params.element.in",
"options[3].object.fieldGroups",
"options[3].object.fields.valueType",
"options[3].object.fields.out.keyType",
"options[3].object.fields.out.valueType",
"options[3].object.lifecycle",
"options[3].object.listViews.valueType",
"options[3].object.listViews.valueType.bulkActionDefs.element",
Expand Down
55 changes: 55 additions & 0 deletions packages/spec/src/automation/builtin-node-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,3 +610,58 @@ describe('assignment value envelope — an evaluated slot requires what the engi
expect(ExpressionSchema.safeParse(BLANK_SOURCE).success).toBe(true);
});
});

/**
* `AssignmentConfigSchema.assignments` — the `__proto__` half of #17852,
* filed on its own as #18847 and folded back into this ruling once PR #18688
* released this file (maintainer ruling A/narrow, comment 5725370319).
*
* `__proto__` ONLY. This slot's key type is `z.string().min(1)` — no
* grammar — so unlike `ObjectSchema.fields` there is no key-refusal half to
* add: `constructor` and `prototype` are legal flow-VARIABLE names today and
* this ruling does not narrow that accept set. `__proto__` is refused for the
* same structural reason as the sibling slot: `z.record()`'s open-key branch
* skips it before any key schema — including `.min(1)` — ever runs.
*/
describe('AssignmentConfigSchema.assignments — __proto__ pre-parse guard, constructor/prototype UNCHANGED (#17852 / #18847)', () => {
it('refuses `assignments` carrying a `__proto__` own key, named at `assignments.__proto__`', () => {
// `JSON.parse` is what makes `__proto__` an OWN enumerable key — an
// object literal's `{ __proto__: ... }` sets the actual prototype
// instead, and would never reach `z.record()`'s open-key loop as a key
// at all.
const config = JSON.parse('{"assignments":{"total":"{amount}","__proto__":"{evil}"}}');
const result = AssignmentConfigSchema.safeParse(config);
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path.join('.') === 'assignments.__proto__');
expect(issue).toBeDefined();
expect(issue?.code).toBe('custom');
expect(issue?.message).toMatch(/__proto__/);
});

it('refuses `assignments` that is `__proto__` ALONE — no sibling key masks the drop', () => {
const config = JSON.parse('{"assignments":{"__proto__":"{evil}"}}');
const result = AssignmentConfigSchema.safeParse(config);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.issues.some((i) => i.path.join('.') === 'assignments.__proto__')).toBe(true);
});

it.each(['constructor', 'prototype'])(
'PRESERVATION: `%s` remains a legal flow-variable name — no ruling narrowed this slot\'s accept set',
(name) => {
const result = AssignmentConfigSchema.safeParse({ assignments: { [name]: '{x}' } });
expect(result.success).toBe(true);
if (!result.success) return;
expect((result.data.assignments as Record<string, unknown> | undefined)?.[name]).toBe('{x}');
},
);

it('an absent `assignments` key still parses (the slot stays optional)', () => {
expect(AssignmentConfigSchema.safeParse({}).success).toBe(true);
});

it('an ordinary `assignments` map with no reserved names still parses', () => {
expect(AssignmentConfigSchema.safeParse({ assignments: { total: '{amount}' } }).success).toBe(true);
});
});
Loading
Loading