Bare Docs

WebAssembly addons

How Bare runs an addon's WebAssembly build sandboxed through WASI, when it picks that build over the native one, and what the sandbox does and doesn't protect.

A native addon is a shared library that Bare loads into the process. Once loaded, it can do anything the process can: open any file the process can open, connect to any host, start threads. From Bare 1.36.0, an addon package can also ship a WebAssembly build of the same code. Bare runs that build sandboxed through WASI, so the files and system calls it can use are limited to what the application grants, and the same file can stand in on platforms the package has no native build for.

Two builds of one addon

A native addon is C or C++ compiled for one platform and architecture, such as darwin-arm64, and installed under prebuilds/<host>/. Bare loads it with Bare.Addon, and the addon calls the libjs C API to create and read JavaScript values.

The WebAssembly build is the same source compiled for wasm32-wasip1 and installed as prebuilds/wasi-wasm32/<name>.wasm. cmake-bare builds it as a WASI reactor whose undefined functions become imports, so its calls to libjs become imports too. Bare doesn't load it as a shared library. The JavaScript engine compiles it, and bare-wasm-abi implements each libjs function it imports in JavaScript. The addon runs in its own linear memory, and strings and buffers are copied in and out of it.

Because the build is one file for every platform, a package that ships it covers platforms it has no native prebuild for, wherever the JavaScript engine provides WebAssembly. The bare-wasm README warns that the WebAssembly global may not be available, and which platforms provide it in Bare 1.36.0 isn't documented upstream.

The behavior on this page was checked with Bare 1.36.0 on macOS.

How Bare picks a build

bare-module 7.5.0, which ships with Bare 1.36.0, decides which build to load:

  • An import with the host attribute, require('bare-sqlite', { with: { host: 'wasi-wasm32' } }), gets the WebAssembly build of every addon in the modules it pulls in.
  • An import without it gets the native build when one exists for the platform, then a linked addon, and the .wasm file only when neither exists.
  • On a process sealed with Bare.Addon.seal(), no new native code can load, so for an addon resolved after the seal, the loader skips native builds the process hasn't loaded yet and falls back to the .wasm file. An addon resolved before the seal still fails with CANNOT_LOAD. That covers every addon reached through string-literal require() and import() specifiers from the sealing module, or from any module linked with it, since those are resolved at link time. A package that ships no .wasm build fails with ADDON_NOT_FOUND.

The addons of one package load one way per module loader, which for an application means one way per process. Importing a package once with host and once without, or with different wasi attributes, throws ADDON_HOST_INCOMPATIBLE or ADDON_WASI_INCOMPATIBLE while the importing module is linked, before its code runs. Bare reads the attributes from the source, so they must be string literals.

What the sandbox grants

Through WASI, a WebAssembly addon gets nothing from the system except clocks unless the application hands it over. The application names a WASI provider with the wasi import attribute: a module that exports a function, which Bare calls with the URL of each WebAssembly addon. What it returns is passed to bare-wasi, which implements WASI preview 1 on a deny-by-default basis:

CapabilityDefaultGranted by
FilesNonepreopens, directories the addon sees at paths of your choosing
Standard input, output, and errorNonestdin, stdout, stderr
RandomnessNonerandom, a function that fills a buffer
Arguments and environment variablesNoneargs, env
ClocksThe host's clocksclock, or clock: null to deny them
SocketsNoneNothing: WASI preview 1 in bare-wasi has no sockets

An addon imported without a provider gets the defaults, which is how the fallback build runs when the application didn't plan for it: it can compute, but it can't open a file.

Directories are confined twice. bare-wasi resolves every path the addon passes, one name at a time, and refuses absolute paths, .. beyond the starting directory, and symbolic links that point outside. A directory on disk is handed over as a bare-directory capability, which opens each name relative to the directory handle with the kernel's confinement flags, such as openat2() with RESOLVE_BENEATH on Linux, and which carries a set of rights that can only be narrowed. An addon given a directory without the write right can't write to it, whatever path tricks it tries.

Because the provider is called once per addon URL, an application can give different addons different capabilities from one provider.

What the sandbox doesn't stop

WASI limits what the addon can reach through system calls. It does not separate the addon from the JavaScript it runs next to.

  • JavaScript's reach. The libjs ABI gives the addon the global object and lets it call any function it can reach, as a native addon can. Whatever JavaScript in the same realm can do, the addon can do. On a process that hasn't called Bare.Addon.seal(), that includes loading a native addon through Bare.Addon. On a sealed process it includes getting back any native addon the process already loaded, by its path. Bare's threat model puts it as WebAssembly addons reaching "no further than JavaScript."
  • The process. The addon shares the process, its CPU time, and its memory limit with everything else. It can loop forever or allocate until the process runs out.
  • The engine. Compiling WebAssembly hands the engine bytes to process, and the threat model lists it among the places where memory bugs in the engine could be reached. Only an operating system sandbox contains those.

So the WASI sandbox is the right tool to keep an addon's file and system access to what it needs, and to stop a buggy addon from writing outside its directory. It isn't a boundary for code you don't trust with your JavaScript realm. For that, the threat model asks for all of these together: seal early, keep untrusted code away from the shared intrinsics with a realm of its own or by freezing them, and run it inside an OS sandbox, which it calls required rather than a bonus.

What an addon can't do as WebAssembly

Some of what a native addon can do has no WebAssembly equivalent, and bare-wasm-abi rejects an addon that imports it:

  • Threads. A WebAssembly addon is single-threaded, so threadsafe functions are out.
  • The event loop. It has no libuv loop, so it can't run work in the background.
  • Shared memory. Its memory is copied in and out, so SharedArrayBuffer and the backing-store functions are out.
  • Compiling code. Running scripts and creating modules is left to the embedder.

A package therefore ships a WebAssembly build only for addons that fit these limits, such as codecs, parsers, and SQLite. Some libjs functions are also not implemented yet: in bare-wasm-abi 0.1.1, the WebAssembly builds of bare-svg 1.1.1 and bare-heif 1.2.1 import js_add_teardown_callback and don't load.

See also

Last updated on

Was this helpful?

On this page