Skip to content
sign in

==> read the decisions

ADR-0023: Project UIs run in sandboxed frames

Status: Accepted (2026-10-15). Amends ADR-0004: a project's UI no longer runs in the app's page; the custom element, the shared theme (ADR-0017) and the release layout stand. Implemented 2026-10-15.

A project's UI is a custom element mounted in the app's own page (ADR-0004), so it runs in the app's origin. Its code -- and every package in its build -- can act as whoever is viewing it: fetch a CSRF token, mint a personal access token and send it away, change the password, end sessions. While each user only opens their own projects that is a user's own code against their own account, but a compromised dependency is enough, and pushing will be opened to more people.

  • A project's UI runs in an iframe with sandbox="allow-scripts allow-forms" -- deliberately without allow-same-origin. (allow-forms because a browser otherwise blocks a form before its submit event, so a project's <form onSubmit> never runs; the frame's policy says form-action 'none', so no form sends itself anywhere.) The frame loads a small shell and the project's bundle from the CDN (/t/<user>/…, as today) and runs in an opaque origin: no cookies, no storage, no access to the app, to the CDN's origin or to another project's frame. Top-level navigation and popups are refused.
  • Its backend calls are bearer calls. The app page hands the frame a short-lived token over postMessage, valid for that project's routes only, and the frame calls api.<domain>/p/<slug>/… with an Authorization header. This frame token is the one narrowly scoped token the platform issues; personal access tokens stay unscoped (ADR-0020). A browser cannot put a header on a WebSocket, so a frame offers its token as a subprotocol (cs, cs.bearer.<token>) and the API answers cs: no stored ticket, and no token in a URL.
  • Theme, mode and language arrive by postMessage on load and on every change; the frame reports its height so the page sizes it.
  • The CDN path sends a strict Content Security Policy and frame-ancestors naming the app only, with the same sandbox as a policy directive, so the shell is sandboxed even if loaded on its own.
  • The change lives in the shared library and the app page: @cs/api's projectApi() asks the parent for a token instead of using the cookie, @cs/ui takes theme and language from messages. User repos rebuild against the new shared library and change nothing else.
  • No per-user origins, probably ever. A separate user-content domain with an origin per user is out of scope for a personal cloud; the sandbox is the isolation.
  • A project's code, or a package in it, can no longer act as the viewer beyond its own project's API, for the few minutes its token lives.
  • Projects have no browser storage (localStorage, IndexedDB and service workers throw in an opaque origin). Their data lives behind their own API, which is where it belongs.
  • Requests from a frame carry Origin: null; with bearer authentication and no cookies, nothing depends on it.
  • Keeping the UI in the app's page. The problem above.
  • An origin per user on a separate domain. Real isolation with storage, at the cost of a second domain, a wildcard certificate and per-user hosts: more than a personal cloud needs.
  • Frames from cdn.<domain> without the sandbox. Every project would share one origin, and that origin is the same site as the app.