diff --git a/README.md b/README.md index f271380..88bfb5a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,71 @@ AT Protocol (XRPC + OAuth). store the `atile` CLI uses — so republishing updates the same record, from either tool. +## Deno Desktop bindings and tile frames + +Bindings (`win.bind()` on the Deno side, `bindings.()` in the webview) +are the one way a tile could reach the Deno runtime, so it's worth knowing how +they scope. Measured under the real `deno desktop` CEF runtime (relaunch with +`BLASTILE_PROBE_BINDING=1`, which registers a diagnostic `probeBinding` on the +main window; the `escape-tile` has a "Call a host binding" probe): + +- **The `bindings` global (the injected Proxy) is present in *every* frame** — + the top app frame *and* the cross-origin, nested tile frame both see + `typeof bindings === 'object'`. Injection is **not** frame- or origin-scoped, + so the *absence* of `bindings` is not a boundary you can rely on. +- **A tile calling a binding does not succeed**: `bindings.probeBinding()` from + the tile frame timed out with no response, whereas the same call from the top + app frame reaches the runtime's dispatcher and gets a definitive answer. So + the cross-origin tile frame's binding invocations don't round-trip. +- **Scoping is per-window, not per-frame.** Deno documents that a binding on + `winA` isn't callable from `winB`, but there is no documented per-frame or + per-origin control, and no way to strip `bindings` from a specific frame. +- **The robust control is architectural, and it's what Blastile does: register + no bindings at all.** With nothing bound, there is nothing for a tile (or the + app) to call, regardless of how injection scopes. If you ever add bindings, + keep them off any window that renders untrusted tiles, and validate inputs as + trust-boundary code. + +(Aside: in this laufey / Deno 2.9.2 build, `win.bind()` on the window created +by `setupMainWindow` never became reachable even from the top app frame — +`No callback bound` — so the app ships with the binding off by default. The +security-relevant finding above holds regardless.) + +## Containment note (found by `escape-tile`) + +What actually contains a tile's network access is the **service worker**: it +intercepts every request from the tile frame and answers it from the tile's own +resources, keyed on pathname alone (the host is ignored). So `fetch`, +`XMLHttpRequest`, `EventSource`, external ` + + diff --git a/examples/escape-tile/manifest.json b/examples/escape-tile/manifest.json new file mode 100644 index 0000000..9c3d8a1 --- /dev/null +++ b/examples/escape-tile/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "Containment Test", + "description": "A red-team tile: it tries every sandbox-escape trick it can (network egress, host scripting, Deno/desktop APIs, powerful features) and reports which were contained and which got through.", + "sizing": { "width": 760, "height": 620 } +} diff --git a/examples/escape-tile/style.css b/examples/escape-tile/style.css new file mode 100644 index 0000000..a3a3120 --- /dev/null +++ b/examples/escape-tile/style.css @@ -0,0 +1,124 @@ +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #0e0e13; + color: #d8d8e2; + font-size: 14px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1.1rem; + border-bottom: 1px solid #26262e; + background: #15151c; +} +.title { + display: flex; + align-items: center; + gap: 0.7rem; +} +.skull { font-size: 1.6rem; } +h1 { + font-size: 1.05rem; + margin: 0; +} +.title p { + margin: 0.1rem 0 0; + font-size: 0.8rem; + color: #8a8a98; +} + +button { + font: inherit; + font-size: 0.82rem; + padding: 0.35rem 0.9rem; + border-radius: 6px; + border: 1px solid #3a3a46; + background: #22222a; + color: inherit; + cursor: pointer; +} +button:hover { background: #2c2c36; } + +.verdict { + margin: 0.9rem 1.1rem; + padding: 0.7rem 1rem; + border-radius: 8px; + font-weight: 600; + border: 1px solid transparent; +} +.verdict.running { background: #1c1c24; color: #9a9aa8; } +.verdict.contained { background: #10281a; color: #7ee2a8; border-color: #1f6b43; } +.verdict.breach { background: #2c1214; color: #ff9b90; border-color: #7a2b2b; } +.verdict .sub { + display: block; + font-weight: 400; + font-size: 0.82rem; + margin-top: 0.25rem; + opacity: 0.85; +} + +.note { + margin: -0.3rem 1.1rem 0.6rem; + padding: 0.6rem 0.9rem; + border-left: 3px solid #f0b23a; + background: #201a10; + color: #d8c69a; + font-size: 0.8rem; + line-height: 1.5; + border-radius: 0 6px 6px 0; +} + +#report { padding: 0 1.1rem 1rem; } + +.group h2 { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #7a7a88; + margin: 1rem 0 0.4rem; +} + +.row { + display: grid; + grid-template-columns: 1.3rem 12rem 1fr; + gap: 0.5rem; + align-items: baseline; + padding: 0.4rem 0.5rem; + border-radius: 6px; +} +.row + .row { border-top: 1px solid #1e1e26; } +.row .icon { text-align: center; } +.row .name { font-weight: 600; } +.row .detail { + color: #9a9aa8; + font-family: ui-monospace, monospace; + font-size: 0.78rem; + word-break: break-word; +} +.row .attempt { + grid-column: 2 / 4; + font-size: 0.76rem; + color: #6f6f7c; +} + +.status-contained .icon { color: #7ee2a8; } +.status-breach { background: #241012; } +.status-breach .icon { color: #ff6b6b; } +.status-breach .name { color: #ff9b90; } +.status-permitted .icon { color: #8ab4ff; } +.status-elevated .icon { color: #f0b23a; } +.status-elevated .name { color: #f0b23a; } +.status-restricted .icon, .status-inconclusive .icon { color: #6f6f7c; } + +footer { + padding: 0.8rem 1.1rem 1.2rem; + font-size: 0.75rem; + color: #6f6f7c; + border-top: 1px solid #1e1e26; +} diff --git a/examples/escape-tile/tile.js b/examples/escape-tile/tile.js new file mode 100644 index 0000000..087991b --- /dev/null +++ b/examples/escape-tile/tile.js @@ -0,0 +1,711 @@ +// Containment Test — a red-team tile. +// +// It attempts a battery of sandbox escapes and reports, per attempt, whether +// the containment held. Each probe declares whether it *should* be blocked by +// the web-tiles sandbox; the report flags any should-be-blocked attempt that +// got through as a BREACH, and marks the handful of things the sandbox +// permits by design (inline scripts, WASM, modals, postMessage back to the +// host) as permitted-by-policy rather than pretending they're blocked. +// +// All network targets are benign public endpoints and no sensitive data is +// ever sent — the probes test *reachability*, not exfiltration. + +const EXTERNAL = 'https://berjon.com'; + +function withTimeout(promise, ms, onTimeout) { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(onTimeout()), ms)), + ]); +} + +// A response body that came from this tile's own resources (via the service +// worker) rather than the external URL. The tile root/markup carries these. +function isTileBody(body) { + return /Containment Test|Attempting to escape|tp-data|web-tiles/i.test(body); +} + +// outcome: 'allowed' (the thing worked) | 'blocked' (it was stopped) | 'inconclusive' +const PROBES = [ + // ---- Network egress: none of this should reach off-origin ---- + { + category: 'Network egress', + name: 'fetch()', + attempt: `fetch('${EXTERNAL}/')`, + shouldBeBlocked: true, + run: async () => { + try { + // The tile's service worker intercepts EVERY request and answers it + // from the tile's own resources keyed on pathname alone (the host is + // ignored). So a fetch that "succeeds" returns the tile's own content, + // never the URL — we must read the body to tell the difference, not + // just check that it didn't throw. + const res = await fetch(`${EXTERNAL}/`, { cache: 'no-store' }); + const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').trim(); + const tileContent = res.status >= 400 || isTileBody(body); + return tileContent + ? { outcome: 'blocked', detail: `service worker intercepted it (status ${res.status}) and returned tile content — never reached ${EXTERNAL}` } + : { outcome: 'allowed', detail: `reached the network: status ${res.status}, body starts "${body.slice(0, 60)}"` }; + } catch (e) { + return { outcome: 'blocked', detail: `${e.name}: ${e.message}` }; + } + }, + }, + { + category: 'Network egress', + name: 'XMLHttpRequest', + attempt: `XHR GET ${EXTERNAL}/`, + shouldBeBlocked: true, + run: () => + new Promise((resolve) => { + try { + const xhr = new XMLHttpRequest(); + xhr.open('GET', `${EXTERNAL}/`); + xhr.onload = () => { + const tileContent = xhr.status >= 400 || isTileBody(xhr.responseText || ''); + resolve(tileContent + ? { outcome: 'blocked', detail: `service worker intercepted it (status ${xhr.status}) — tile content, not the network` } + : { outcome: 'allowed', detail: `reached the network: status ${xhr.status}` }); + }; + xhr.onerror = () => resolve({ outcome: 'blocked', detail: 'network error' }); + xhr.send(); + setTimeout(() => resolve({ outcome: 'blocked', detail: 'no response in 3s' }), 3000); + } catch (e) { + resolve({ outcome: 'blocked', detail: `${e.name}: ${e.message}` }); + } + }), + }, + { + category: 'Network egress', + name: 'WebSocket', + attempt: 'wss:// to an echo server', + shouldBeBlocked: true, + run: () => + new Promise((resolve) => { + // WebSocket is NOT a fetch, so the service worker cannot intercept it. + // Containment here would have to come from the tile document's + // connect-src CSP — which it doesn't have. We confirm a real round-trip + // (send + echo) rather than trusting onopen alone. + let done = false; + const finish = (outcome, detail) => { + if (done) return; + done = true; + try { ws.close(); } catch { /* ignore */ } + resolve({ outcome, detail }); + }; + let ws; + try { + ws = new WebSocket('wss://echo.websocket.org/'); + } catch (e) { + return finish('blocked', `${e.name}: ${e.message}`); + } + ws.onopen = () => ws.send('blastile-ping'); + ws.onmessage = (ev) => finish('allowed', `network reached — server echoed ${JSON.stringify(String(ev.data)).slice(0, 40)} (bypasses the service worker)`); + ws.onerror = () => finish('blocked', 'connection failed'); + setTimeout(() => finish('blocked', 'no echo within 4s'), 4000); + }), + }, + { + category: 'Network egress', + name: 'EventSource (SSE)', + attempt: `new EventSource('${EXTERNAL}')`, + shouldBeBlocked: true, + run: () => + new Promise((resolve) => { + let done = false; + const finish = (outcome, detail) => { + if (done) return; + done = true; + resolve({ outcome, detail }); + }; + let es; + try { + es = new EventSource(`${EXTERNAL}/sse`); + } catch (e) { + return finish('blocked', `${e.name}: ${e.message}`); + } + es.onopen = () => { finish('allowed', 'stream opened — network reached'); es.close(); }; + es.onerror = () => { finish('blocked', 'errored (SSE is a fetch, so the service worker answers it with non-event-stream content)'); es.close(); }; + setTimeout(() => { finish('blocked', 'no connection in 3s'); try { es.close(); } catch { /* ignore */ } }, 3000); + }), + }, + { + category: 'Network egress', + name: 'sendBeacon', + attempt: `navigator.sendBeacon('${EXTERNAL}', …)`, + shouldBeBlocked: true, + run: () => { + try { + // sendBeacon returns true once the request is *queued* — and the queued + // request is still intercepted by the service worker like any fetch, so + // "true" does not prove the network was reached. It also can't read a + // response, so it can't confirm interception either. + const ok = navigator.sendBeacon(`${EXTERNAL}/beacon`, 'ping'); + return ok + ? { outcome: 'inconclusive', detail: 'queued (still routed through the service worker) — queuing is not proof the network was reached' } + : { outcome: 'blocked', detail: 'sendBeacon returned false' }; + } catch (e) { + return { outcome: 'blocked', detail: `${e.name}: ${e.message}` }; + } + }, + }, + { + category: 'Network egress', + name: 'WebRTC / STUN', + attempt: 'RTCPeerConnection to a STUN server', + shouldBeBlocked: true, + run: () => + new Promise((resolve) => { + if (typeof RTCPeerConnection === 'undefined') { + return resolve({ outcome: 'blocked', detail: 'RTCPeerConnection not exposed' }); + } + let done = false; + let sawRemote = false; + const finish = (outcome, detail) => { + if (done) return; + done = true; + try { pc.close(); } catch { /* ignore */ } + resolve({ outcome, detail }); + }; + let pc; + try { + pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); + } catch (e) { + return resolve({ outcome: 'blocked', detail: `${e.name}: ${e.message}` }); + } + // WebRTC is not a fetch either, so the service worker can't touch it, + // and connect-src doesn't reliably gate ICE — a server-reflexive + // candidate means a real round-trip to the STUN server happened. + pc.onicecandidate = (ev) => { + if (ev.candidate && /typ (srflx|relay)/.test(ev.candidate.candidate)) { + sawRemote = true; + finish('allowed', 'reached the STUN server (server-reflexive candidate) — bypasses the service worker, real egress'); + } + }; + pc.createDataChannel('probe'); + pc.createOffer() + .then((o) => pc.setLocalDescription(o)) + .catch((e) => finish('blocked', `offer failed: ${e.message}`)); + setTimeout( + () => finish(sawRemote ? 'allowed' : 'blocked', sawRemote ? 'reached STUN' : 'only host candidates — no STUN reachable'), + 4000, + ); + }), + }, + { + category: 'Network egress', + name: 'External