# Interactive Widget Sandboxing — Security Model **Status:** Stable draft for MVP implementation **Related:** [03-content-format.md](03-content-format.md), ADR-011, ADR-012 --- ## 1. The problem Interactive widgets (draggable geometry, simulations, circuit builders, custom visualizations) are the platform's most valuable content — and its largest attack surface. Widgets are community-contributed code. The cardinal rule: > **Community widget code never executes in the trusted origin, on the server, or with access to user credentials. Ever.** We define three trust tiers and a hard architectural boundary between them. ## 2. Trust tiers | Tier | What | Runs where | Review bar | |---|---|---|---| | **T0 — Core components** | First-party MDX components (``, `
`, ``, plots from data, code highlighting). Maintained in the platform repo | Main app origin, normal React | Platform code review + CI | | **T1 — Declarative widgets** | Parameterized, data-only configurations of T0 engines (e.g., a JSXGraph scene described purely as JSON, a parameter slider bound to a KaTeX expression). **No author-supplied code.** | Main app origin; the engine interprets data | Schema validation + content review | | **T2 — Code widgets** | Author-written JS/TS widgets (arbitrary logic) | **Sandboxed cross-origin iframe only** | Widget registry review (§6) + content review | The MVP ships T0 and T1 fully, and T2 with the registry pipeline below. Most pedagogical needs are deliberately pushed into T1 — it is dramatically safer and easier to review. Content reviewers can see at a glance whether a problem uses any T2 widget (the pre-screen flags it). **Code-challenge execution** (running learner-submitted Python/JS against tests) is a separate system — server-side sandboxed runners — covered in §8. It shares principles but not infrastructure with widget sandboxing. ## 3. T2 sandbox architecture ```mermaid flowchart LR subgraph trusted["app.fablepool.example (trusted origin)"] Host[WidgetHost component] Bridge[postMessage bridge

+ schema validator] end subgraph sandbox["widgets.fablepool-usercontent.example (sandbox origin)"] Frame[iframe sandbox=...
strict CSP] Runtime[widget-runtime.js
(platform-provided)] Code[widget bundle
(community code)] end Host --> Frame Bridge <-->|"postMessage (validated envelopes)"| Runtime Runtime --> Code CDN[(content-addressed
widget bundles, immutable)] --> Frame ``` ### 3.1 Origin isolation - Widgets are served from a **dedicated registrable domain** (`fablepool-usercontent.example`), not a subdomain of the app, so cookies, storage, and same-site privileges can never leak. The sandbox origin sets no cookies at all. - Bundles are immutable, content-addressed files: `https://widgets…/w/{sha256}/index.html`. Cache-Control: `public, max-age=31536000, immutable`. ### 3.2 iframe attributes ```html ``` - `sandbox="allow-scripts"` only. Crucially **no `allow-same-origin`** — the frame gets an opaque origin: no storage, no cookies, no readable origin even on its own host. No `allow-popups`, no `allow-top-navigation`, no `allow-forms`, no `allow-modals`. - Permissions-Policy (`allow=""`) denies camera, mic, geolocation, fullscreen, etc. ### 3.3 CSP on the sandbox origin Served with every widget document: ``` Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'none'; frame-ancestors https://app.fablepool.example; base-uri 'none'; form-action 'none'; ``` - `connect-src 'none'` — **widgets cannot make network requests.** All data a widget needs is passed in via the bridge at init. This single rule eliminates exfiltration, tracking, and SSRF-by-proxy classes entirely. (A future, separately-reviewed capability may allow fetching from a platform-proxied, allowlisted data endpoint; not in MVP.) - `script-src 'self'` with no `unsafe-eval` — bundles are pre-built; no runtime eval. - `frame-ancestors` pins embedding to the app, preventing widget pages from being framed by phishing sites to borrow our UI legitimacy. ### 3.4 postMessage bridge protocol The only channel between widget and host. Every envelope is validated with a JSON Schema on **both** sides (the platform-provided `widget-runtime.js` validates host→widget; the host bridge validates widget→host) and `event.origin` / `event.source` are strictly checked on the host side. ```ts type Envelope = { v: 1; // protocol version id: string; // message id (for request/response) type: "init" | "ready" | "state" | "answer" | "resize" | "telemetry" | "error"; payload: unknown; // schema per type, size-capped (64 KB) }; ``` Host → widget: `init` (widget params from the content document, current saved state, locale, theme tokens, reduced-motion flag). Widget → host: `ready`, `resize` (height; clamped 100–2000 px, rate-limited), `state` (opaque state blob for persistence; ≤ 32 KB, debounced), `answer` (a candidate answer payload — see below), `error`. **Widgets never grade themselves.** An `answer` message carries data (e.g., the coordinates the learner chose); grading happens server-side against the problem's answer spec (or, for `interactive` answer type, against a server-registered checker — never trusting the frame). A compromised widget can at worst submit a wrong answer for the learner, never a falsely-correct one. `telemetry` is limited to a fixed enum of interaction events with no free-form strings, preventing covert exfiltration through analytics. ### 3.5 Resource governance - One widget iframe mounted per viewport at a time on low-bandwidth mode; others render a static poster image (captured at registry review time) with "tap to activate". - Host watchdog: if a frame fails to send `ready` within 10 s, or floods > 30 messages/s, it is torn down and replaced by the poster + error notice. - Bundle size hard cap: 1 MB compressed per widget version (registry-enforced); encourages T1 usage. ## 4. What this model does and does not defend **Defended:** credential theft (origin isolation), data exfiltration (no network), persistent tracking (no storage, no cookies), UI redressing of the host (frame is visually contained; host renders all trust chrome like "Submit answer" *outside* the frame), navigation hijack, popup spam, self-grading fraud. **Residual risks, accepted with mitigations:** - **CPU burn / crypto-mining in-frame:** mitigated by lazy activation, watchdog teardown on unresponsiveness, and registry review; cannot be fully prevented in-browser. - **Offensive content drawn inside the canvas:** mitigated by registry review + the standard report/retract pipeline. - **Browser zero-days escaping the sandbox:** out of scope; defense in depth (separate registrable domain) limits blast radius. ## 5. Widget state & privacy - Widget `state` blobs are stored server-side keyed by `(user, problem_version, widget_instance)`; they are opaque to the platform and re-injected on `init`. - State is per-version: a `major` content change invalidates saved widget state. - The privacy policy treats widget state as user content; it is included in GDPR export/erasure. ## 6. Widget registry & review pipeline T2 widgets are first-class versioned entities (`Widget`, `WidgetVersion`), separate from problems: 1. Author develops against the published `@fablepool/widget-runtime` SDK (typed bridge, theming tokens, a11y helpers) with a local harness (`npx fablepool-widget dev`). 2. Submission uploads source + lockfile; the platform **builds the bundle itself** in CI (no author-supplied binaries), producing a reproducible, content-addressed artifact. Build runs in a network-restricted container with a dependency allowlist (npm packages must be on the curated list or individually approved). 3. Automated checks: bundle size, banned APIs static scan (`eval`, `document.cookie`, `WebSocket`, `fetch`/`XMLHttpRequest` usage is flagged — they'd be blocked by CSP anyway, belt-and-braces), a11y smoke test (axe-core on the harness), poster screenshot capture. 4. Human review by reviewers holding the `reviewer:widgets` scope (code review, behavior review). 5. On acceptance, the bundle hash is registered; **problems may only reference registered `(widgetId, versionHash)` pairs** — the content pre-screen (doc 06 §5.5) enforces this. Problems pin exact widget versions (same philosophy as course→problem pinning); widget updates flow to problems via the maintainer "refresh pins" path. ## 7. Server-side hardening checklist - App origin CSP forbids `frame-src` except the widget origin; forbids inline scripts (nonce-based). - Widget origin serves `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: cross-origin` (bundles only), and is on the public-suffix-style isolation domain. - Upload pipeline never serves user uploads from the app origin (all media on the asset domain with `Content-Disposition` enforcement for non-image types). - SVG uploads are sanitized (scripts/foreignObject stripped) since they render on the asset origin as images. ## 8. Code-challenge execution (related but separate) Learner code for code challenges runs **server-side** in single-use sandboxes: - Runner: per-language OCI containers executed under **gVisor** (`runsc`) on dedicated runner nodes, never on app servers. (Firecracker microVMs are the scale-up path; gVisor chosen for MVP operational simplicity — ADR-012.) - Limits: no network namespace, read-only rootfs + tmpfs `/tmp` (64 MB), 0.5 vCPU, 256 MB RAM, 5 s CPU / 10 s wall, 64 process cap, output capped at 256 KB. - Flow: API enqueues `{submission, tests, limits}` → runner pulls from Redis queue → executes → posts signed result. Test fixtures are mounted read-only; expected outputs never enter the container environment where learner code could read them (graders compare outside the sandbox). - Every execution is audit-logged (user, problem version, runtime, exit status, resource peak).