Security implications of Wasm in enterprise apps

This page walks through the concrete task of hardening a WebAssembly deployment for an enterprise web app: building a threat model, securing the .wasm supply chain, locking down which capabilities each module receives, and isolating third-party modules so a compromised dependency cannot pivot into the rest of the page.

The engine-level sandbox already does a lot for you. A module starts with zero ambient authority and every memory access is bounds-checked — the full mechanics are in browser sandbox & security boundaries. What the sandbox does not do is protect you from the capabilities you choose to grant, or from shipping a tampered binary. That gap is the enterprise security problem.

What actually goes wrong with a third-party module The sandbox removes memory-disclosure risk but not the three that matter in practice: an unverified artifact entering the build, an import object that grants more authority than the module needs, and a module that consumes memory or CPU without limit. 1 · supply chain where did this .wasm come from? who can replace it? controls: pinned hash, signed release, reproducible build, SRI on delivery the sandbox does nothing here 2 · granted capability what did the import object hand over? network? storage? user data? controls: narrow wrappers, validation, no raw fetch, no raw storage handle this is where review time belongs 3 · resource exhaustion unbounded memory.grow or a loop that never returns controls: maximum on the memory, run it in a worker you can terminate a hang is an availability incident "It runs in a sandbox" answers a question nobody was asking. These three are the ones an enterprise review will raise. All three have concrete, checkable controls — which is what makes the review tractable.

Prerequisites

  • [ ] wabt ≥ 1.0.34 (wasm-validate, wasm-objdump, wasm2wat) and binaryen (wasm-opt)
  • [ ] A CI runner (GitHub Actions, GitLab CI) you can add a blocking security gate to
  • [ ] Server control over response headers for CSP, COOP/COEP, and application/wasm MIME type
  • [ ] An inventory of every .wasm artifact and its provenance (first-party build vs npm/crate dependency)
  • [ ] Node 20+ for the build-time hashing and allowlist scripts below

Procedure

1. Build the threat model

Enumerate what an attacker gains by compromising a module, given the sandbox boundary. The realistic threats are not memory-safety escapes — those are blocked — but abuse of granted capabilities and supply-chain tampering:

Threat Vector Sandbox stops it?
Exfiltration via a granted fetch import You imported a network function No — you granted it
Tampered binary shipped from a CDN Compromised CDN / MITM No — needs SRI / hash pin
Resource exhaustion Unbounded memory.grow Partly — needs explicit maximum
Memory corruption of the host Buffer overflow in module Yes — bounds-checked linear memory
Side-channel timing leak High-res timer in shared memory Only outside cross-origin isolation

The model’s output is a per-module capability budget: the minimal set of imports each module is allowed to receive.

2. Secure the .wasm supply chain

Treat every third-party .wasm (including ones pulled transitively through an npm package) as untrusted input. Pin a content hash at build time and refuse to instantiate anything that does not match.

# Record the canonical hash of the artifact you reviewed
shasum -a 384 dist/module.wasm | awk '{print "sha384-"$1}' | \
  openssl base64 -A   # produces the value for an SRI attribute

Optimize and strip the binary so the deployed artifact is minimal and reproducible, then validate it:

wasm-opt --strip-debug --strip-producers -O2 dist/module.wasm -o dist/secure.wasm
wasm-validate dist/secure.wasm

3. Vet imports against an allowlist

Before instantiation, read the module’s declared imports and reject anything outside the budget from step 1. A module that suddenly demands a fetch import it never needed is a red flag.

const mod = await WebAssembly.compileStreaming(fetch("/secure.wasm"));
const allow = new Set(["env.log", "env.now"]);          // the entire budget
for (const imp of WebAssembly.Module.imports(mod)) {
  const key = `${imp.module}.${imp.name}`;
  if (!allow.has(key)) throw new Error(`unvetted import: ${key}`);
}

4. Instantiate with a least-privilege import object

Grant only the audited functions, cap linear memory, and keep memory non-shared until a thread-safety review is done.

const memory = new WebAssembly.Memory({ initial: 1, maximum: 16, shared: false }); // 1 MiB ceiling
const { instance } = await WebAssembly.instantiateStreaming(fetch("/secure.wasm"), {
  env: {
    memory,
    log: (ptr, len) =>
      console.log(new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, Math.min(len, 4096)))),
    abort: () => { throw new Error("wasm abort"); },
  },
});

5. Sandbox third-party modules in a Worker

A .wasm you do not fully trust should run in a dedicated Web Worker with its own isolated import object, so even the capabilities you grant it are scoped to that worker and cannot see the main thread’s DOM or state. The worker becomes a second containment ring around the engine’s own sandbox; the main thread communicates only through a narrow postMessage protocol you control.

6. Deploy CSP and SRI headers

Allow Wasm compilation with the narrow keyword — never re-enable JavaScript eval — and force every subresource to opt in:

Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'none';
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Add the SRI hash from step 2 to the <script type="module"> loader so a tampered bundle is rejected by the browser before it runs.

7. Gate it all in CI

Make the audit a blocking pipeline step so a regression cannot ship.

# .github/workflows/wasm-security.yml
name: Wasm Security Audit
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get install -y binaryen wabt
      - name: Optimize & strip
        run: wasm-opt --strip-debug --strip-producers -O2 ./dist/*.wasm -o ./dist/secure.wasm
      - name: Validate structure
        run: wasm-validate ./dist/secure.wasm
      - name: Inspect memory ceiling
        run: wasm2wat ./dist/secure.wasm | grep "(memory"
      - name: Enforce import allowlist
        run: npm run test:wasm-imports

Expected output / verification

A correctly hardened deployment passes these checks. From the Console:

// imports are exactly the budgeted set, nothing more
console.log(WebAssembly.Module.imports(mod));
// [{ module: "env", name: "memory", kind: "memory" },
//  { module: "env", name: "log", kind: "function" }]

// the memory ceiling is enforced — growth past 16 pages fails
console.log(instance.exports.memory.grow ? memory.grow(64) : "n/a"); // -1, not a throw

From the CLI, wasm-objdump -x dist/secure.wasm should show no debug section, the declared memory maximum, and an import list matching your allowlist. A failing wasm-validate must block the pipeline.

Each review question has one artifact that answers it The import list from wasm-objdump answers what the module can reach. The declared memory maximum answers how much it can consume. The build provenance answers where the bytes came from. Each is a file or a command, not an opinion. "What can this module reach?" wasm-objdump -x module.wasm | grep Import "How much memory can it take?" wasm2wat module.wasm | grep '(memory' "Where did these bytes come from?" pinned version + recorded hash in the lockfile Attach the three outputs to the review. They convert a security conversation about a binary into three reproducible facts.

Gotchas

  • unsafe-eval smuggled in alongside Wasm. Teams sometimes add 'unsafe-eval' to make Wasm compile, which silently re-enables JavaScript eval() and new Function() across the app. Use 'wasm-unsafe-eval' — it permits only WebAssembly.

  • COEP: require-corp breaks third-party assets. Once you enable cross-origin isolation, every CDN font, image, or analytics script without a Cross-Origin-Resource-Policy header fails to load and crossOriginIsolated flips to false. Audit every subresource before flipping these headers in prod.

  • SRI on the loader does not cover the .wasm. A <script integrity> hash protects the JS glue, not the binary it fetches. You must hash-check the .wasm bytes yourself (step 3) or the binary remains a tamper point.

  • Trusting a length passed from the module. Bounds checking protects the module’s own memory; it does not stop your log import from reading the wrong slice if you trust an attacker-supplied len. Clamp it, as the import above does.

Documenting the decision

Whatever you conclude, write it down where the next reviewer will find it. A short record — which module, which version, what it is allowed to reach, what limits are imposed, and who approved it — turns a repeated argument into a reference. It also makes the renewal cheap: when the vendor ships a new version, the question is what changed against that record rather than a fresh evaluation from first principles.

Keep the record next to the code rather than in a separate system, and include the three outputs from the checklist above so the claims are verifiable rather than asserted. A reviewer who can run wasm-objdump -x and get the same import list as the document has a meaningfully different level of assurance from one reading a summary.

Performance note

The expensive part of this hardening is one-time, not per-call. Hash verification of a 200 KiB module takes well under a millisecond with crypto.subtle.digest, and import allowlist checks run once at instantiation. Capping maximum memory has zero runtime cost — it only bounds memory.grow. The single ongoing cost is running an untrusted module in a Worker, which adds postMessage serialization; for that, share a SharedArrayBuffer to avoid copying large payloads, accepting the cross-origin-isolation requirement that comes with it.

One organisational point is worth making explicitly, because it decides how much of the above you actually need. A module you build from source you control is a different risk profile from a binary a vendor ships you. In the first case the interesting questions are about your own build pipeline; in the second, you are accepting an artifact you cannot read, and every control has to live at the boundary — the import object you construct, the memory maximum you declare, and the worker you can terminate. Treat those two cases with different review depth rather than applying one checklist to both.

Two provenance cases, two sets of controls For a module built from source you control, the review focuses on the pipeline: pinned toolchain, reproducible output, and dependency review. For a vendor binary you cannot read, the review focuses on the boundary: minimal imports, a declared memory maximum, and a terminable worker. you build it from source review the pipeline: pinned toolchain, reproducible output, dependency audit the binary is a build artifact like any other your existing supply-chain process applies a vendor ships you the binary review the boundary: minimal imports, memory maximum, terminable worker you cannot audit the code, only the contract so the contract is where the effort goes Applying the vendor checklist to your own build wastes review time; applying the first-party one to a vendor binary is wishful thinking.

Frequently Asked Questions

Can a malicious .wasm escape the browser sandbox and read host memory? No. Every memory access is bounds-checked into the module’s own linear memory, return addresses live in protected engine state, and indirect calls are type-checked. The realistic risk is a module abusing the capabilities you imported, not breaking out of the engine.

Do I need different controls for server-side Wasm (e.g. WASI on the edge)? Yes. Browser controls assume the engine grants no filesystem or network by default; WASI runtimes grant those through host functions and capability flags. The same allowlist discipline applies, but the budget includes file and socket capabilities you must scope explicitly.

How do I map this audit to SOC 2 or ISO 27001 evidence? Log the binary’s SHA-256, its import signature, and its memory maximum to your SIEM on every deploy, and treat a failing wasm-validate or allowlist test as a control failure. That gives you an auditable trail tying each shipped artifact to a reviewed, bounded capability set.

← Back to Browser Sandbox & Security Boundaries