Caputchin
Understanding Caputchin

How Caputchin sandboxes games

A Caputchin game is third-party code that runs in your visitor's browser, on your page. Whether it comes from the marketplace or you host it yourself, Caputchin treats it as untrusted by default and wraps it in several independent isolation layers, so a buggy or hostile game can render, play, and report a result, but can reach nothing else: not your page, not your visitor's data, not the network.

This page explains every measure, why each exists, and how the whole thing interacts with a CSP you add to your own site.

The threat model in one line

The game runs arbitrary JavaScript (and possibly WebAssembly) supplied by a third party. The isolation goal is therefore: the game can compute and draw, and it can hand a result back to the widget, but it cannot read or affect anything outside its own frame. Every layer below serves that one goal, and they are defence in depth, no single layer is trusted to be perfect.

Layer 1: an opaque-origin sandboxed iframe

Every game runs inside an <iframe> the widget builds with a deliberately minimal sandbox attribute. The only token granted is allow-scripts, because the game is JavaScript and must run. Everything else is withheld, and the most important omission is allow-same-origin.

Without allow-same-origin the browser gives the frame a unique, null origin. That single fact is the load-bearing boundary:

  • The game cannot read your page's DOM, cookies, localStorage, or the Caputchin session, it is sealed into a throwaway origin that is foreign to yours.
  • Because the frame can also never navigate your top window, open popups, submit forms, or pop native dialogs (all of those capabilities are sandbox tokens Caputchin does not grant), there is no path from the game back out to your page.

The combination of allow-scripts without allow-same-origin is the whole point: the browser executes the game's code but treats the frame as a foreign origin that can touch nothing of yours. Caputchin never adds allow-same-origin to a game frame. (As a consequence, every message the game posts to the widget arrives from origin "null", which the widget's channel checks expect, a non-null origin would itself signal a misconfigured sandbox.)

Layer 2: the frame's document is built inline, not fetched

The widget does not point the iframe at a remote page. It constructs the frame's entire HTML document inline (via srcdoc) and hands it to the null-origin frame. That document is tiny: a strict CSP meta tag (next layer), a small Caputchin runtime bootstrap, and one <script> tag that loads the game bundle.

This is the same mechanism for both delivery paths, only the bundle URL differs:

Game sourceBundle URL in the inline document
Marketplace (platform-resolved)The pinned, integrity-hashed bundle URL Caputchin resolved at mount.
Self-hosted (your game-src)The URL you supplied.

Because the document is authored by the widget rather than the game, the game never controls the CSP, the runtime, or the frame's structure, only what its own bundle does once loaded.

Layer 3: Subresource Integrity for marketplace games

When Caputchin serves a marketplace game it pins the bundle to an immutable version and records a cryptographic hash (SHA-384) of it. The <script> tag that loads the bundle carries that hash as an integrity attribute with crossorigin="anonymous", so the browser itself refuses to execute the bundle if a single byte differs from what was pinned. A compromised CDN cannot substitute different code; the load fails closed.

A self-hosted game has no platform-asserted hash (you are the trust root for your own bytes), so the integrity attribute is simply omitted for that path. See the replay contract for how a marketplace game's result is independently re-derived on the server, a separate guarantee from integrity.

Layer 4: a strict inline Content-Security-Policy

The inline document carries a tight Content-Security-Policy. Even though the frame is already null-origin and sandboxed, the CSP further constrains what the loaded code may do, it denies everything by default and re-grants only the minimum:

DirectiveValueWhy
default-src'none'Deny everything not explicitly allowed below.
script-srca hash of Caputchin's own inline runtime + the game bundle's origin + 'wasm-unsafe-eval'Run only Caputchin's exact bootstrap (pinned by a sha256- hash, not 'unsafe-inline') and the game's own bundle. 'wasm-unsafe-eval' lets a WASM engine compile without granting full 'unsafe-eval'.
connect-src'none'The anti-exfiltration directive. The game cannot fetch, XHR, open a WebSocket, or beacon anywhere. No network egress at all.
img-srcdata: (plus any skin-asset origins, below)Sprites inline, or from an allowed asset host.
media-srcdata: (plus any skin-asset origins, below)Audio/video inline, or from an allowed asset host.
font-srcdata:Inline fonts only.
style-src'unsafe-inline'Games set inline styles; no external stylesheets. (Styles cannot exfiltrate data the way script or network can.)

The net effect: the game runs, renders, and reports its result through the SDK bridge, and can reach nothing else, especially not the network. This CSP is set by Caputchin and is not something you configure; it is listed here so you understand exactly how confined a game is.

The one place a customer setting widens the frame CSP

Skins can point image or audio fields at an absolute URL on your own CDN (see skins). For those assets to load inside the locked-down frame, Caputchin adds only those exact asset origins to the frame's img-src / media-src, and nowhere else. script-src and connect-src are never widened, so even an allowed asset origin cannot be used to load code or open a network channel. This is the single, narrow way a configured value affects the frame's policy, and it is still asset-only.

Layer 5: a one-way result channel

The game has no callable surface into your page. The only way out is the SDK bridge: the game calls a single method that postMessages its opaque result (its trace) to the widget, which lives on your origin. The widget is what talks to Caputchin's API; the game never does (it cannot, connect-src is 'none'). So the trust path is game → widget → your backend, and each hop only ever passes a result forward, never grants the game reach backward.

The CSP you set on your own page

The layers above protect you and your visitor from the game. A Content-Security-Policy on your own page is a different, complementary control: it protects your page from everything, and Caputchin is designed to run under a strict one. You do not have to relax your CSP to embed Caputchin; you have to allow the few origins the widget legitimately uses.

At minimum, the widget needs:

DirectiveAllowWhy
script-srcthe origin you load the widget script from, plus 'unsafe-eval'The <script> that defines <caputchin-widget> / <caputchin-game>, the CDN you chose (jsDelivr, your own host, or caputchin.com). 'unsafe-eval' lets the instrumentation challenge run; it is required while instrumentation is on, and you can drop it by turning instrumentation off on the key's Security page.
connect-srchttps://caputchin.comThe widget calls the Caputchin API to set up and confirm a verification. If you point the widget at a different API host, allow that instead.
frame-srchttps://caputchin.comGoverns the game iframe. Because the frame uses an inline srcdoc document, browsers apply your frame-src to it, so allow the Caputchin origin here.
worker-srcblob:The proof-of-work solver runs entirely in Web Workers created from a blob: URL, with no main-thread fallback, so this is required. If you omit worker-src, browsers fall back to child-src then default-src, and default-src 'self' alone does not permit blob:.

Optionally add 'wasm-unsafe-eval' to script-src: it lets the proof-of-work solver run as fast WebAssembly. It is not required, the solver falls back to a slower pure-JavaScript implementation (still inside the Worker) without it.

You do not need to allow the game's own bundle origin (jsDelivr, a game's CDN, your game-src host) in your page CSP: that bundle loads inside the sandboxed frame, under the frame's own CSP described above, not under your page's policy. Your page only frames Caputchin; Caputchin confines what runs inside.

If a Caputchin element silently fails to load under your CSP, your browser console names the blocked directive; add the origin or keyword it names to that directive. Start strict and open exactly what the console asks for. You never need 'unsafe-inline'. You do need blob: for workers (worker-src) and, while instrumentation is on, 'unsafe-eval' in script-src; the 'unsafe-eval' requirement goes away if you turn instrumentation off on the key's Security page. 'wasm-unsafe-eval' is optional (it speeds up the proof-of-work solver, which otherwise runs slower in JavaScript inside the same Worker).

Why so many layers

Each layer would be a meaningful boundary on its own; together they mean a failure in one is not a breach. The null origin alone already walls the game off from your data; the CSP alone already kills network egress; integrity alone already pins the exact bytes. Caputchin runs all of them because untrusted third-party code is exactly the place where defence in depth earns its keep.

See also

On this page