Caputchin
Understanding Caputchin

How Caputchin sandboxes games

View as Markdown

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.

One fact drives everything in this section: the game frame inherits your page's policy. The frame is an inline srcdoc document, and browsers apply the embedding page's CSP to those on top of the frame's own. So the frame's locked-down policy shown above is a floor that Caputchin adds, not a replacement for yours. Anything that runs inside the frame has to satisfy both, which means your page policy has to permit the game bundle and the frame's own bootstrap, even though neither is something your page loads directly.

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.
script-src (games only)the game bundle's origin, plus 'unsafe-inline' and 'wasm-unsafe-eval'Because the frame inherits this policy. The bundle origin is https://games.caputchin.com for marketplace games, or your own host / CDN for a self-hosted game. 'unsafe-inline' covers the frame's own bootstrap script, which the frame pins by sha256- hash but your page cannot, because that hash changes with every widget release. 'wasm-unsafe-eval' is needed by games built on a WebAssembly engine. None of this applies if you only use <caputchin-widget> without a game.
connect-srchttps://verify.caputchin.com, plus the origin you load the widget script fromTwo separate calls. The widget calls the Caputchin verification service to set up and confirm a verification, so allow that exact origin (if you point the widget at a different API host, allow that instead). The proof-of-work solver additionally fetches its WebAssembly module from alongside the widget script, so the CDN origin belongs here as well as in script-src. Self-hosting the widget on your own origin covers the second one with 'self'.
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:.

Add 'wasm-unsafe-eval' to script-src to let the proof-of-work solver run as fast WebAssembly. Neither that keyword nor the solver's fetch origin is required for verification to succeed: if either is missing, the solver falls back to a slower pure-JavaScript implementation (still inside the Worker) and verification still works. But they only pay off together. The solver fetches the WebAssembly module, then compiles it, so the fast path needs the widget script's origin in connect-src and 'wasm-unsafe-eval' in script-src; allowing one without the other leaves you on the slow solver with nothing in the console to say why.

You do not need a frame-src entry for the game. The game frame is an inline srcdoc document rather than a document fetched from a URL, so there is no URL for frame-src to match, and the frame loads even under frame-src 'none'.

What you do need, and what surprises people, is the game bundle's origin in your page's script-src, even though your page never loads that bundle: the frame does, and the frame runs under your policy as well as its own. The same goes for the frame's bootstrap and for a WebAssembly game engine. Granting them does not widen what the game can reach: the frame's own policy still pins connect-src 'none', the frame is still opaque-origin, and your page's grant cannot loosen a floor the frame sets on itself.

'unsafe-inline' in script-src is the uncomfortable part of that list, and it is honest to say so: it is required today to run a game, it weakens your page's own inline-script protection, and the alternative (pinning the frame bootstrap's sha256- hash) is not practical while that hash moves with each widget release. If that trade is not acceptable for a given page, use <caputchin-widget> there, which needs none of the games-only row.

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, and read the violations reported against about:srcdoc as carefully as the ones against your own page: those are the game frame hitting your inherited policy, and they are the ones people miss. 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. You need 'unsafe-inline' only to run games, per the table above. The proof-of-work fast path is optional but paired: 'wasm-unsafe-eval' in script-src and the widget script's origin in connect-src. That pair is the one case the console will not help you with, because falling back to the JavaScript solver is not an error.

A policy that works

Loading the widget from jsDelivr, using marketplace games, with instrumentation on:

default-src 'none';
script-src  https://cdn.jsdelivr.net https://games.caputchin.com 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval';
connect-src https://verify.caputchin.com https://cdn.jsdelivr.net;
worker-src  blob:;
img-src     'self' data: https:;
style-src   'unsafe-inline'

Drop https://games.caputchin.com, 'unsafe-inline' and 'wasm-unsafe-eval' if you do not use games. Drop 'unsafe-eval' if you turn instrumentation off. Swap https://cdn.jsdelivr.net for your own origin in both script-src and connect-src if you self-host the widget.

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