sandbox: make plain <form> submission work in member apps (no security change) (#1180)
* docs: member-app guide — stop recommending <form>; teach button + keydown
The sandboxed member-app authoring guide (served at /api/member-app-guide,
and the reference the bot builder generates against) recommended
`<form>` + `onsubmit`. That's wrong for this sandbox: the frame has no
`allow-forms`, so any real form submission logs
"Blocked form submission … 'allow-forms' is not set" to the console —
and preventDefault does NOT suppress it (verified in Chromium; neither an
onsubmit handler nor a global capture-phase handler stops the message,
they only stop the navigation).
Replace the guidance with the sandbox-safe pattern everywhere it appears:
a plain <div> container, `<button type="button">` + onclick, and Enter
wired via a keydown listener — no <form> element. Updated: hard
constraint 13 (split into "wire via onclick" + a new constraint 14
"never use a <form>/native submit"), the "Emitting events" example, and
the complete worked example (habit tracker), including setting
type="button" on every button since a typeless button defaults to submit.
Web-only: this is the sandboxed member-app guide. Trusted custom apps run
in the page (not sandboxed), so their guide (/api/app-guide) is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* sandbox: make plain <form> submission work in member apps (no security change)
Member apps run in an opaque-origin iframe with no `allow-forms`, so any
real form submission is blocked AND logs "Blocked form submission … the
'allow-forms' permission is not set". That message is emitted while the
submission ALGORITHM runs, and canceling the `submit` event does NOT
suppress it (verified in Chromium) — only preventing the algorithm from
STARTING does. The old global `addEventListener("submit", preventDefault)`
therefore stopped the navigation but left the console error, so member
apps that followed the guide's `<form>`+`onsubmit` pattern surfaced it.
Fix in sandbox-frame.js's boot: intercept the submission TRIGGERS in
capture — a submit button's click (via isSubmitTrigger) and an Enter
implicit-submit (via isEnterSubmit) — cancel them so the algorithm never
starts, and re-dispatch a SCRIPTED `submit` event. A scripted dispatch
runs the app's `onsubmit` without performing native submission, so a plain
`<form onsubmit>` just works: no navigation, no console error. A
double-fire guard skips the synthetic submit when the button handles its
own click, so the `<button type=button onclick>` pattern is untouched. A
`submit`-level preventDefault stays as a navigation backstop.
No security change: still `sandbox="allow-scripts"` (no allow-forms), still
CSP `form-action 'none'`; the shim only re-dispatches the app's own event
on its own form. isSubmitTrigger/isEnterSubmit are pure + unit-tested
(test/sandbox.test.mjs). The member-app guide's constraint 13 is updated to
explain accurately why `<form>` works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
3 files changed,
+97
−10
docs/building-member-apps.md+8 −4test/sandbox.test.mjs+29 −0web/static/sandbox-frame.js+60 −6
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md| index 2abf455..73821a2 100644 |
| --- a/docs/building-member-apps.md |
| +++ b/docs/building-member-apps.md |
| @@ -104,10 +104,14 @@ These fields still do **nothing** in a member app — do not emit them: |
| 12. **You never touch the server, keys, or tokens.** |
| 13. **Interact only through `ctx.emit` — never the browser's own actions.** Wire |
| *every* control to an emit: a `<button>`'s `onclick`, or a `<form>`'s |
| - `onsubmit` (which must call `ctx.emit(...)`). A bare `<form>` submit or an |
| - `<a href>` navigation does **nothing** here — the sandbox has no network and |
| - no navigation, so an unwired control is dead. A `<form>` is fine as layout, |
| - but its submit is swallowed; put the work in `onsubmit`. |
| + `onsubmit` (which must call `ctx.emit(...)`). An `<a href>` navigation does |
| + **nothing** here — the sandbox has no navigation, so an unwired control is |
| + dead. A `<form>` **works**: native submission has nowhere to go (there's no |
| + `allow-forms`), so the sandbox runtime intercepts the submit — a submit |
| + button's click, or Enter in a field — and re-dispatches it as a scripted |
| + `submit`, so your `onsubmit` runs, nothing navigates, and there's no console |
| + error. Put the work in `onsubmit` and `ctx.emit(...)` from there. (You never |
| + need `preventDefault` for that reason — though it's harmless.) |
| ## The shape of an app: `defineApp(spec)` |
diff --git a/test/sandbox.test.mjs b/test/sandbox.test.mjs| index 759c00a..2612b26 100644 |
| --- a/test/sandbox.test.mjs |
| +++ b/test/sandbox.test.mjs |
| @@ -38,6 +38,8 @@ import { |
| evalMemberApp, |
| normalizeCommands, |
| manifestOf, |
| + isSubmitTrigger, |
| + isEnterSubmit, |
| } from "../web/static/sandbox-frame.js"; |
| import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, sanitizePanelSpec, registerMemberApp, resolveDark, manifestIsLegacy } from "../web/static/sandbox.js"; |
| import { tokenizeText as realTokenize, formatBytes as realBytes } from "../web/static/format.js"; |
| @@ -675,3 +677,30 @@ test("evalMemberApp captures a panel hook; the mood example evaluates whole", as |
| assert.equal(validateManifest(m), null); |
| assert.equal(m.chat, true); |
| }); |
| + |
| +// The form-submission shim (bootFrame): a member app can use a plain |
| +// <form onsubmit> because the runtime intercepts the submission TRIGGER (a |
| +// submit button's click, an Enter implicit-submit) and re-dispatches a scripted |
| +// submit — no native submission, so no "Blocked form submission" console error |
| +// and no navigation, with the sandbox unchanged (still no allow-forms). These |
| +// pin the pure trigger predicates the shim keys on. |
| +test("isSubmitTrigger: submit buttons / image inputs / typeless buttons in a form", () => { |
| + assert.equal(isSubmitTrigger("BUTTON", null, true), true, "typeless <button> defaults to submit"); |
| + assert.equal(isSubmitTrigger("BUTTON", "submit", true), true); |
| + assert.equal(isSubmitTrigger("INPUT", "submit", true), true); |
| + assert.equal(isSubmitTrigger("INPUT", "image", true), true); |
| +}); |
| +test("isSubmitTrigger: NOT a trigger for type=button, plain inputs, or no form", () => { |
| + assert.equal(isSubmitTrigger("BUTTON", "button", true), false, "type=button never submits"); |
| + assert.equal(isSubmitTrigger("INPUT", "text", true), false); |
| + assert.equal(isSubmitTrigger("BUTTON", "submit", false), false, "no associated form"); |
| + assert.equal(isSubmitTrigger("A", null, true), false); |
| +}); |
| +test("isEnterSubmit: Enter in a single-line input in a form, respecting Shift/IME", () => { |
| + assert.equal(isEnterSubmit("Enter", false, false, "INPUT", true), true); |
| + assert.equal(isEnterSubmit("Enter", true, false, "INPUT", true), false, "Shift+Enter = newline"); |
| + assert.equal(isEnterSubmit("Enter", false, true, "INPUT", true), false, "mid IME composition"); |
| + assert.equal(isEnterSubmit("Enter", false, false, "TEXTAREA", true), false, "textarea keeps Enter"); |
| + assert.equal(isEnterSubmit("Enter", false, false, "INPUT", false), false, "no associated form"); |
| + assert.equal(isEnterSubmit("a", false, false, "INPUT", true), false); |
| +}); |
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js| index 529490c..4f4c501 100644 |
| --- a/web/static/sandbox-frame.js |
| +++ b/web/static/sandbox-frame.js |
| @@ -386,6 +386,30 @@ export function normalizeCommands(cmds) { |
| const CHAT_CAP = 200; |
| const CHAT_TEXT_CAP = 400; |
| +// isSubmitTrigger — would activating this element START native form submission? |
| +// True for a submit button, an image input, or a TYPELESS <button> (which |
| +// defaults to type="submit"), when it belongs to a form. Pure + exported so |
| +// it's unit-tested; bootFrame's click handler uses it to catch the trigger |
| +// BEFORE the browser runs the sandbox-blocked, error-logging submission |
| +// algorithm (see the shim in bootFrame for why the trigger, not the submit |
| +// event, is what must be cancelled). |
| +export function isSubmitTrigger(tagName, typeAttr, hasForm) { |
| + if (!hasForm) return false; |
| + const tag = String(tagName || "").toUpperCase(); |
| + const t = String(typeAttr || "").toLowerCase(); |
| + if (tag === "INPUT") return t === "submit" || t === "image"; |
| + if (tag === "BUTTON") return t === "" || t === "submit"; // typeless button = submit |
| + return false; |
| +} |
| + |
| +// isEnterSubmit — would this keydown IMPLICITLY submit a form? Enter, without |
| +// Shift, not mid-IME-composition, in a single-line <input> that belongs to a |
| +// form. (Textareas keep Enter for newlines; buttons/other targets are ignored.) |
| +export function isEnterSubmit(key, shiftKey, isComposing, tagName, hasForm) { |
| + return key === "Enter" && !shiftKey && !isComposing && !!hasForm |
| + && String(tagName || "").toUpperCase() === "INPUT"; |
| +} |
| + |
| function bootFrame() { |
| let port = null; |
| let spec = null; |
| @@ -756,12 +780,42 @@ function bootFrame() { |
| draw(); |
| }; |
| - // Native form submission has nowhere to go in this sandbox — no network, no |
| - // navigation (allow-forms isn't set, and the CSP forbids it anyway) — so the |
| - // browser blocks it with a scary "Blocked form submission … 'allow-forms' is |
| - // not set" and the app appears to do nothing. Swallow the default globally so a |
| - // <form> is harmless: the app's own submit handler still runs and can |
| - // ctx.emit(). Apps interact by emitting events, never by navigating. |
| + // Make a plain <form onsubmit> work in this sandbox WITHOUT loosening it. |
| + // Native submission has nowhere to go (no allow-forms, and the CSP forbids |
| + // it), so the browser blocks it AND logs a scary "Blocked form submission … |
| + // 'allow-forms' is not set". Crucially, that message is emitted while the |
| + // submission ALGORITHM runs, and canceling the `submit` event does NOT |
| + // suppress it (verified) — only preventing the algorithm from STARTING does. |
| + // So intercept the triggers (a submit button's click, an Enter implicit |
| + // submit) in capture, cancel them, and re-dispatch a SCRIPTED `submit` event: |
| + // a scripted dispatch runs the app's onsubmit but performs no native |
| + // submission — so no navigation and no console error, and the sandbox stays |
| + // exactly as tight. Apps may use <form onsubmit> or a <button onclick>; both |
| + // just work. |
| + const fireSubmit = (form, submitter) => { |
| + const ev = typeof SubmitEvent === "function" |
| + ? new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: submitter || null }) |
| + : new Event("submit", { bubbles: true, cancelable: true }); |
| + form.dispatchEvent(ev); |
| + }; |
| + document.addEventListener("click", (e) => { |
| + const b = e.target && e.target.closest && e.target.closest("button, input[type=submit], input[type=image]"); |
| + if (!b || !isSubmitTrigger(b.tagName, b.getAttribute("type"), !!b.form)) return; |
| + e.preventDefault(); // stop the native submission → no console error |
| + // If the button handles its OWN click, don't also synthesize a submit — its |
| + // onclick covers it (avoids a double-fire); otherwise re-dispatch so a pure |
| + // submit button's form.onsubmit still runs. |
| + if (typeof b.onclick !== "function") fireSubmit(b.form, b); |
| + }, true); |
| + document.addEventListener("keydown", (e) => { |
| + if (!isEnterSubmit(e.key, e.shiftKey, e.isComposing, e.target && e.target.tagName, !!(e.target && e.target.form))) return; |
| + e.preventDefault(); |
| + fireSubmit(e.target.form, null); |
| + }, true); |
| + // Backstop: cancel the navigation on any real submit that slips past the |
| + // triggers above (e.g. a programmatic form.requestSubmit()). This does NOT |
| + // suppress the console error for that path — only the trigger intercept does — |
| + // so it's defense in depth, not the fix. |
| document.addEventListener("submit", (e) => e.preventDefault(), true); |
| // The host initialises us off our iframe's `load` event (see initMount in |