Skip to content

Security: monteslu/jsgamelauncher

Security

docs/SECURITY.md

jsgamelauncher security model

Status: implemented on branch vm-realm-sandbox. On main (≤ v0.9.0) the model is the OPPOSITE — see "History" at the bottom.

The model: games run in a BROWSER sandbox

jsgamelauncher games are browser games — a browser gives them ZERO filesystem or OS access and they run fine. So the launcher runs each game inside an isolated node:vm realm (realm.js) whose globals are only the browser surface (window / document / canvas / WebGL / WebAudio / Image / fetch / localStorage / FontFace / gamepad-via-navigator / requestAnimationFrame / Worker / SharedArrayBuffer / Atomics). It is NOT the main Node global scope.

Game code sees NONE of these: process, require, global, __dirname, fs, child_process, Buffer-as-node, or any Node builtin. By any path:

  • require('fs') — require is undefined.
  • import('fs') / import 'fs' — the realm's ESM loader rejects bare and node: specifiers with a hard error.
  • There is no vm-escape via a leaked global, because the dangerous globals were never put in the context (this is removal, not a wrapper/Proxy you must keep airtight).

A hostile game's require('fs').rmSync('/home/...') fails at require itself; an import('child_process') rejects. Same boundary a browser tab gives.

How threading still works (without leaking Node)

emscripten-compiled wasm (e.g. box2d3 deluxe) threads via new Worker(url, {type:'module'}) + SharedArrayBuffer. Because the realm has NO process, emscripten takes its browser code path (ENVIRONMENT_IS_NODE is false) and uses Worker. The realm's Worker (GameWorker in realm.js) is a real worker_threads worker under the hood, running worker-module-bootstrap.mjs — but that worker_threads plumbing is entirely inside the runtime, never exposed to game code. The worker runs the (trusted, bundled) wasm module; it cannot reach or weaken the main realm's sandbox. SharedArrayBuffer/Atomics (how a pthread shares memory) are browser-standard and safe to expose.

DON'T re-break it — the failure modes

These changes would silently destroy the sandbox; reviewers must reject them:

  1. Putting the game back in the main scope (await import(gameFile) instead of realm.runEntry(...)). The whole boundary is the vm context; bypassing it = full Node again.
  2. Exposing process/require/fs/global/__dirname in the realm sandbox (even a "minimal stub"). A real process gives process.env (secrets), process.binding, process.exit. A real fs is game-over. (An earlier jsgame-libretro attempt did exactly this with a "neutered fs + real process" — it leaked. The fix was to expose NOTHING and let emscripten use its browser path. Do the same here.)
  3. Letting the realm's ESM loader resolve bare/node specifiers "for convenience." That re-opens import('fs').
  4. Replacing GameWorker with web-worker (the npm package). It runs the worker in a context with real Node and doesn't complete emscripten's pthread handshake — both wrong.

Verify (adversarial test)

Drop a game whose main is:

console.log('process='+typeof process, 'require='+typeof require,
            'global='+typeof global, '__dirname='+typeof __dirname);
(async()=>{ try{await import('fs')}catch{console.log('fs blocked')} })();

Expected: process=undefined require=undefined global=undefined __dirname=undefined

  • fs blocked. And typeof Worker === 'function', typeof SharedArrayBuffer === 'function' (so threads still work).

Behavior changes — NOT breaking for real games

No real browser game uses fs/process/Node builtins, so removing them breaks nothing that should exist. The notes below are the only differences:

  • The realm requires --experimental-vm-modules. The sandbox uses vm.SourceTextModule, which lives behind that flag. cli.js sets it automatically by re-execing node; an app that embeds launch() must start node with it (launch() throws a clear error otherwise). This is the same flag Jest requires for ESM tests — production-stable, just not yet un-flagged in Node.
  • node_modules resolves. Unbundled import 'three' works in dev with no build step: the realm resolves bare specifiers against the game's node_modules. Bundled .jsgame games use relative imports only.
  • Node builtins are blocked. import 'fs' / import 'child_process' (and the globals process/require/fs) hard-error. The only "casualty" is a non-portable hack like process.exit() — which never worked in a browser either, so it's a correctness fix, not a regression. (A correctly-guarded typeof process !== 'undefined' check just no-ops, as it should.)

Threat model

With the realm: untrusted games are sandboxed to browser-level capability. A downloaded/shared .jsgame-style game cannot read your files, run shell commands, or read env secrets. (Still not a hardened multi-tenant boundary — vm is isolation, not a security VM — but it removes the file/shell/process RCE surface, which is the thing that mattered.)

History (pre-vm-realm-sandbox, ≤ v0.9.0)

The game ran in the main Node global scope via await import(gameFile) with real process/require/fs/child_process ambient, PLUS auto-npm install of the game's deps on first launch (running arbitrary postinstall scripts). So merely launching an untrusted game directory was arbitrary code execution. That is the model the vm-realm-sandbox branch replaces. If you are on main and running untrusted content: don't — it has full Node privileges.

There aren't any published security advisories