Skip to content

Commit 9d4dfc4

Browse files
os-zhuangclaude
andauthored
feat(spec): rule and enforce submitBehavior.url — relative-only, declared-field interpolation, URL-escaped (#7496) (#7657)
* feat(spec): rule and enforce submitBehavior.url — relative-only, declared-field interpolation, URL-escaped (#7496) `FormView.submitBehavior: { kind: 'redirect' }`'s `url` was an unconstrained `z.string()`, so `https://example.com`, `//evil.example`, `javascript:alert(1)` and `''` all parsed. objectui's `FormPage` redirects verbatim on the value, and objectui#4190 dead-ended asking what the value may be — nothing in this package had ruled it. The maintainer ruled the narrowest shape on 2026-08-11 (#7496); this lands the spec half, documented AND enforced at the schema door: 1. relative paths only — a single leading `/`, with absolute (any `scheme:`), protocol-relative `//host`, backslash, whitespace/control, document-relative and empty forms each refused by their own message, naming the rule and citing the ruling; 2. interpolation only from declared record fields, spelled `{{record.field_name}}` (the ADR-0032 §3 dialect, narrowed to the `record.` root and a flat snake_case segment), URL-escaped when the redirect is built; 3. a verbatim redirect on the resolved relative path is the intended consumption — so `url` stays a plain string on the parsed output. Field-EXISTENCE validation is deliberately not here: parse time knows the string and not the object, so it belongs to the reference-integrity family in `@objectstack/lint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CJkvhk41XD5vUxg1fpoF6H * chore(changeset): record the ADR-0087 disposition for the submitBehavior.url narrowing (#7496) The Check Changeset gate requires a declared-breaking changeset to answer the ADR-0087 question in writing. Answered `not-required (no-migration-prescription)`: no key is retired — only the accepted VALUE set narrows — and the replacement path for an absolute URL is a human decision no codemod can derive, so a ledger entry would promise a rewrite that cannot exist. The schema rejection is the upgrade channel, and it is more specific than a ledger line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CJkvhk41XD5vUxg1fpoF6H --------- Co-authored-by: os-zhuang <noreply@anthropic.com>
1 parent 6979157 commit 9d4dfc4

7 files changed

Lines changed: 552 additions & 7 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec): rule and enforce `submitBehavior.url` — relative-only, declared-field interpolation, URL-escaped (#7496)
6+
7+
⚠️ **This NARROWS an acceptance surface.** `FormView.submitBehavior: { kind: 'redirect' }`'s
8+
`url` was an unconstrained `z.string()`, so `https://example.com/thanks`,
9+
`//evil.example`, `javascript:alert(1)` and `''` all parsed. They no longer do.
10+
Metadata authoring one of those forms now fails to parse — at `defineStack`, at
11+
`os validate`, and at the save-time 422 — instead of reaching the renderer.
12+
13+
The reason it was open is that nothing had ever ruled what the key means.
14+
objectui's `FormPage` redirects **verbatim** on this value, and objectui#4190
15+
dead-ended asking what the value may be: path, address, or template was
16+
undecided, so the consumer could not be hardened without inventing the contract
17+
on the spec's behalf. The maintainer ruled the narrowest shape on 2026-08-11
18+
(#7496), and this lands the spec half of it — documented **and** enforced:
19+
20+
1. **Relative paths only.** The value must start with a single `/`. Refused, each
21+
with its own message naming the rule and citing the ruling: absolute URLs
22+
(any `scheme:`, so `javascript:` and `data:` come with it), protocol-relative
23+
`//host` (the one that looks relative and is not), backslashes (browsers
24+
normalise `\` to `/`, so `/\host` leaves the origin exactly like `//host`),
25+
smuggled whitespace and control characters (browsers strip them before
26+
resolving, which is how a leading-slash check gets walked past),
27+
document-relative `thanks` / `./thanks`, and the empty string. This is what
28+
closes the open-redirect face: a post-submit target is authored metadata, and
29+
AI-authored metadata is precisely where an `https://` someone else chose gets
30+
copied in.
31+
2. **Interpolation only from declared record fields**, spelled
32+
`{{record.field_name}}` — the ADR-0032 §3 double-brace dialect the platform
33+
already uses, narrowed to the `record.` root (the record just submitted is all
34+
a post-submit moment has) and to a flat field segment in the same lowercase
35+
snake_case grammar `object.fields` keys are declared under. Every interpolated
36+
value is **URL-escaped** when the redirect is built, so a token is a value in
37+
the path or query and can never add path structure. Any other brace shape —
38+
`{{page.x}}`, `{{os.user.id}}`, single-brace `{record.id}`, an unclosed token,
39+
a stray brace — is refused with the one spelling that works.
40+
3. **A verbatim redirect on the resolved relative path is the intended
41+
consumption**, so `url` stays a plain `string` on the parsed output: wrapping
42+
it in a template `Expression` envelope would change what the renderer reads.
43+
44+
Widening (an allowlist of absolute origins, say) waits for measured demand — the
45+
ruling took the narrowest shape deliberately.
46+
47+
**What is NOT enforced here, and where it belongs.** Parse time knows the string;
48+
it does not know the object. Whether `{{record.foo}}` names a field this form's
49+
object actually declares needs both, so it belongs to the reference-integrity
50+
family in `@objectstack/lint`, which already resolves field references against
51+
object declarations — not to this refine. Enforcing the shape loudly beats
52+
enforcing none of it.
53+
54+
**Migrating.** Replace an absolute `url` with the in-app path
55+
(`https://example.com/thanks``/thanks`). To send the browser out of the app
56+
deliberately, that is an app navigation item (`{ type: 'url', url }`), which is
57+
declared for external addresses — a form's submit behavior is not.
58+
59+
The consumer half — objectui resolving and escaping the tokens rather than
60+
redirecting on the raw string — is scoped after this in objectui#4190. Until it
61+
lands, the renderer still redirects verbatim, so a token would reach the browser
62+
literally; the forms guide says so where the rules are documented.
63+
64+
<!-- adr-0087: not-required (no-migration-prescription) Nothing is retired: `submitBehavior.url` keeps its declaration, its spelling, its type and its arm — what moves is the set of VALUES the key accepts, from "any string" to the ruled relative-path shape. There is no tombstone to write, and no mechanical FROM/TO rule a ledger entry could state, because the transform is not derivable: the in-app path that should replace `https://example.com/thanks` is a decision about where that form's submitter belongs, and no codemod can read it out of the old value — the old value is precisely the destination the ruling says must stop being honoured. `objectstack migrate meta` would therefore have nothing to apply, and a ledger entry naming this surface would promise an automatic rewrite that cannot exist. The upgrade channel is the schema rejection itself, which is strictly more specific than any ledger line could be: it fires at the author's own path, names which of the six refused forms was written, states the rule and the ruling date, and prescribes the fix (write the in-app path; use an app navigation item for a deliberate external link). Measured blast radius in this repo: zero — no `examples/*` app, fixture or test authors a non-conforming `submitBehavior.url` (the only `kind: 'redirect'` fixtures are `view-strictness-batch18.test.ts`'s `'/x'` and `'/done'`, already conforming), and the one absolute URL that existed was a commented-out example in `content/docs/ui/forms.mdx`, corrected in this PR. -->
65+

content/docs/references/ui/view.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on a
283283
| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections |
284284
| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
285285
| **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form |
286-
| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior |
286+
| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). |
287287
| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). |
288288
| **defaults** | `Record<string, any>` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). |
289289
| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to rewrite existing sources automatically. |

content/docs/ui/forms.mdx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,8 @@ formViews: {
379379
message: 'A specialist will reach out within 24 hours.',
380380
},
381381

382-
// Or: redirect (great for marketing pages)
383-
// submitBehavior: { kind: 'redirect', url: 'https://example.com/thanks', delayMs: 500 },
382+
// Or: redirect to an in-app path (relative only — see below)
383+
// submitBehavior: { kind: 'redirect', url: '/thanks', delayMs: 500 },
384384

385385
// Or: reset the form for another response (kiosks, batch entry)
386386
// submitBehavior: { kind: 'continue' },
@@ -398,6 +398,31 @@ formViews: {
398398
| `continue` | Re-read prefill values and reset state — user can submit another response without reload. |
399399
| `next-record` | Stub for queue contexts; falls back to thank-you when no queue is wired. |
400400

401+
### What a `redirect` `url` may be
402+
403+
`url` is **not** a free-form address. It was ruled on 2026-08-11 ([#7496](https://github.com/objectstack-ai/objectstack/issues/7496)) and the schema enforces it, so a URL outside this shape is a parse error at authoring time rather than a surprise in the browser:
404+
405+
1. **Relative paths only.** The value must start with a single `/`. Absolute URLs (`https://example.com/thanks`, and equally `javascript:` / `data:`), protocol-relative `//example.com/thanks`, backslashes, and smuggled whitespace or control characters are all refused. A post-submit redirect is authored metadata that sends a real browser somewhere — leaving it open to any address makes every form an open redirect waiting for one careless copy-paste. To send someone **out** of the app deliberately, that is an app navigation item (`{ type: 'url', url }`), which is declared for external addresses.
406+
2. **Interpolation only from declared record fields**, spelled `{{record.field_name}}` — the same double-brace template dialect the rest of the platform uses, narrowed to the record that was just submitted and to a flat field name. Every interpolated value is **URL-escaped** when the redirect is built, so a token is a *value* in the path or query and can never add path structure.
407+
3. **A verbatim redirect on the resolved relative path is the intended consumption** — what the renderer navigates to is exactly this string with its tokens substituted.
408+
409+
```ts
410+
// ✅ accepted
411+
submitBehavior: { kind: 'redirect', url: '/thanks' }
412+
submitBehavior: { kind: 'redirect', url: '/records/{{record.id}}', delayMs: 500 }
413+
submitBehavior: { kind: 'redirect', url: '/thanks?ref={{record.public_ref}}' }
414+
415+
// ❌ refused, with the rule named in the error
416+
submitBehavior: { kind: 'redirect', url: 'https://example.com/thanks' } // absolute
417+
submitBehavior: { kind: 'redirect', url: '//example.com/thanks' } // protocol-relative
418+
submitBehavior: { kind: 'redirect', url: 'thanks' } // document-relative
419+
submitBehavior: { kind: 'redirect', url: '/u/{{os.user.id}}' } // not a record field
420+
```
421+
422+
Widening this — an allowlist of absolute origins, say — waits for measured demand; the ruling took the narrowest shape deliberately.
423+
424+
> **Current renderer status (2026-08-11).** The console shipping at the pinned `.objectui-sha` redirects **verbatim** on this value: it does not yet substitute `{{record.…}}` tokens or escape them. The three rules above are the contract, and the consumer half is tracked in [objectui#4190](https://github.com/objectstack-ai/objectui/issues/4190). Until it lands, a token would reach the browser literally — so write a plain path unless you are following that issue.
425+
401426
### The default is mode-aware
402427

403428
`submitBehavior` is optional, and **what you get for omitting it depends on the mode** — a public collection form and an authed create have different right answers (ruled 2026-08-10 on [#7245](https://github.com/objectstack-ai/objectstack/issues/7245)):

0 commit comments

Comments
 (0)