eleven / messenger Public

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>
James Adam fa01466a9892213c9dca8b050208339c93e64709 parent 0b50a9d
3 files changed, +97 −10
  • docs/building-member-apps.md +8 −4
  • test/sandbox.test.mjs +29 −0
  • web/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