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
19 changes: 19 additions & 0 deletions .changeset/27606-public-form-option-subset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/rest': patch
---

fix(rest)!: the public form submit door refuses a value outside a form row's declared `options` subset (#27606)

Clause-②: no (narrowing) — `POST /forms/:slug/submit` now refuses a value outside the `options` subset a form entry declares, which it used to store with `201`. Nothing is widened.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: one REST door now enforces the `options` subset a form entry already declares and `GET /forms/:slug` already serves. There is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at the anonymous public form submit door (`@objectstack/rest`).

- **Before.** A form entry such as `{ field: 'priority', options: [low, medium, high] }` narrowed what the form offered, and `GET /forms/:slug` served that subset, but `POST /forms/:slug/submit` judged keys and the form's `required` only. The engine judges a value against the object's options, so a guest could store `critical` through that form with `201`.
- **Now.** The door answers `400 VALIDATION_FAILED` before anything is written, naming the field in `fields[]` with the ADR-0114 code `invalid_option`, the catalog's message, and `constraint.allowed` listing the subset's values only. It is the same envelope the form's `required` and file refusals use, so a submission with several problems is told all of them at once. A multi-valued field is judged element by element, the first outside element named.
- **Unchanged.** A value inside the subset saves as before. An empty or absent optional value is not judged. An entry that lists no `options` keeps the object's full set, judged by the engine as before, and so does a field that another entry of the same form offers without a subset.
- **Who sees it.** A client posting to a published form a value the form does not offer: a script or bot, or the console's public form page, which still lists every option of the field until its renderer reads the row's subset (a separate `domain:ui` change).

**The remedy.** Send one of the values the form serves for that field in `GET /forms/:slug`, or remove the entry's `options` to offer the object's full set.
2 changes: 1 addition & 1 deletion content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry

### 2. The field whitelist *is* the security boundary

Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. 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.
Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. An entry that lists `options` offers that subset of the field's members, and the submit route refuses a value outside it with `400 VALIDATION_FAILED` naming the field (`invalid_option`, listing the subset's values only); an entry that lists none offers the object's full set. 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.

The whitelist cannot hold a file field (`file`, `image`, `avatar`, `video`, `audio`): a visitor has no upload route, so a public form declaring one is refused at `os validate` / `os build` and at publish (`public-form-file-field`). Collect files from registered, signed-in users on an internal form instead.

Expand Down
97 changes: 89 additions & 8 deletions packages/rest/src/form-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,34 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
// the refusal below for why a hidden or conditionally visible
// entry is not judged here.
const requiredFields: string[] = [];
// [#27606] …and the `options` subset each field's entries offer:
// the union of every entry that lists `options`. An entry that
// lists none (or a bare-string entry) offers the object's full
// set, so it marks the field `null`, not narrowed, whatever
// another entry lists. See the refusal below.
const optionSubsets = new Map<string, Set<string> | null>();
for (const section of match.form?.sections ?? []) {
const sectionConditional = section?.visibleWhen != null;
for (const f of section?.fields ?? []) {
if (typeof f === 'string') allowedFields.add(f);
else if (f?.field) {
if (typeof f === 'string') {
allowedFields.add(f);
optionSubsets.set(f, null);
} else if (f?.field) {
allowedFields.add(f.field);
const offered = Array.isArray(f.options) && f.options.length > 0 ? f.options : null;
const sofar = optionSubsets.get(f.field);
if (offered === null || sofar === null) {
optionSubsets.set(f.field, null);
} else {
const subset = sofar ?? new Set<string>();
for (const o of offered) {
// A bare-string option is its own value, as the engine's
// and the console's option readers take it.
const v = typeof o === 'string' ? o : o?.value;
if (v !== undefined && v !== null) subset.add(String(v));
}
optionSubsets.set(f.field, subset);
}
if (
f.required === true
&& f.hidden !== true
Expand Down Expand Up @@ -616,20 +638,45 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
// `400 VALIDATION_FAILED` with `fields[]`, so a submission with
// both problems is told both at once. The field code is the
// closed ADR-0114 catalog's `invalid_value`.
// [#27606] A value outside the `options` subset a form entry
// offers is refused, naming the field, before anything is
// written. `GET /forms/:slug` serves that subset; the engine
// judges a value against the OBJECT's options only, so this door
// stored a member the form never offered (a guest filing a
// `critical` case through a form that offers up to `high`).
//
// - Judged on what this door WRITES, with the same emptiness
// rule as `required`: an empty or absent value is not judged.
// - A multi-valued value is judged element by element, the first
// outside element named, as the engine judges the object's set.
// - The same envelope again, with the closed catalog's
// `invalid_option`, worded by the catalog (`invalid_option`,
// or `invalid_option_value` for an element). The allowed values
// it names are the subset's only, which the form publishes.
// - A field no entry narrows keeps the object's full set, judged
// by the engine as before.
// The declared fields this submission carries a value in: the
// only ones the file judgement can refuse, so a submission that
// carries none reads no object declarations for it.
const fileValued = [...allowedFields].filter((name) =>
// only ones the file and subset judgements can refuse, so a
// submission that carries none reads no object declarations for
// them.
const valued = [...allowedFields].filter((name) =>
Object.prototype.hasOwnProperty.call(filteredData, name)
&& !isMissing(filteredData[name]));
if (judged.length > 0 || fileValued.length > 0) {
if (judged.length > 0 || valued.length > 0) {
const objectDef = (await this.readFormObjectDefinitions(p, environmentId))
.find((o: any) => o?.name === match.object);
const labelOf = (def: any, name: string): string => {
const declared = typeof def?.label === 'string' ? def.label.trim() : '';
return declared.length > 0 ? declared : name;
};
const refused: Array<{ field: string; code: 'required' | 'invalid_value'; message: string; label: string }> = [];
const refused: Array<{
field: string;
code: 'required' | 'invalid_value' | 'invalid_option';
message: string;
label: string;
constraint?: Record<string, unknown>;
value?: string;
}> = [];
for (const name of judged) {
const def = objectDef?.fields?.[name];
const value = Object.prototype.hasOwnProperty.call(filteredData, name)
Expand All @@ -647,7 +694,7 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
label,
});
}
for (const name of fileValued) {
for (const name of valued) {
const def = objectDef?.fields?.[name];
if (!def || typeof def.type !== 'string' || !FILE_REFERENCE_TYPES.has(def.type)) continue;
if (isEmptyForRequired(def, filteredData[name])) continue;
Expand All @@ -660,6 +707,40 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
label,
});
}
for (const name of valued) {
const subset = optionSubsets.get(name);
// A subset that collected no values narrows nothing (fail
// open to the engine's judgement, never refuse every value).
if (!subset || subset.size === 0) continue;
const def = objectDef?.fields?.[name];
const value = filteredData[name];
const empty = def && typeof def.type === 'string'
? isEmptyForRequired(def, value)
: isMissing(value);
if (empty) continue;
const elements = Array.isArray(value) ? value : [value];
const outside = elements.findIndex((v) => !subset.has(String(v)));
if (outside < 0) continue;
const allowed = [...subset].join(', ');
const label = labelOf(def, name);
refused.push({
field: name,
code: 'invalid_option',
message: Array.isArray(value)
? renderValidationMessage({
messageKey: 'invalid_option_value',
label,
field: name,
params: { allowed, value: String(elements[outside]) },
})
: renderValidationMessage({ messageKey: 'invalid_option', label, field: name, params: { allowed } }),
label,
constraint: { allowed },
// The offending element, as the engine's multi-valued
// `invalid_option` ships it; its single-value arm ships none.
...(Array.isArray(value) ? { value: String(elements[outside]) } : {}),
});
}
if (refused.length > 0) {
throw validationFailure(refused.map((f) => f.message).join('; '), refused);
}
Expand Down
Loading
Loading