Back to posts

How This Blog Is Made

·Nataphon Kabkaew

In March 2022, the maintainer of node-ipc (a package with 1M+ weekly downloads) pushed an update that wiped files on machines with Russian or Belarusian IP addresses. The malicious code ran during npm install via a postinstall script—no user action required.

This blog uses npm packages like React and MDX, but they run in a sandbox where they cannot access your files, environment variables, or network. Here's how.

The Core Idea

Instead of running npm packages with full system access, we execute them inside GraalVM's JavaScript engine with all I/O disabled:

Context.newBuilder("js")
    .allowIO(IOAccess.NONE)           // No file system
    .allowHostAccess(HostAccess.NONE) // No Java interop
    .allowCreateProcess(false)        // No spawning processes
    .allowEnvironmentAccess(NONE)     // No env vars
    .build()

The JavaScript can transform text (compile MDX → HTML), but it cannot:

  • Read ~/.aws/credentials or ~/.ssh/id_rsa
  • Access process.env.GITHUB_TOKEN
  • Make HTTP requests to exfiltrate data
  • Execute shell commands

Why This Matters

npm supply chain attacks are not theoretical. Here are incidents where build-time code execution caused real damage:

PackageDownloadsWhat Happened
event-stream (2018)2M/weekStole Bitcoin from Copay wallets by injecting code into the build
ua-parser-js (2021)8M/weekInstalled crypto miners and password stealers via postinstall
node-ipc (2022)1M/weekWiped files based on geolocation; affected Vue CLI users
colors + faker (2022)20M/weekMaintainer sabotaged packages with infinite loops

In each case, the attack vector was the same: code that runs during npm install or build has full system access.

How the Build Works

Here's the actual build pipeline:

MDX File (trusted)

GraalVM Sandbox (isolated)

@mdx-js/mdxreactreact-dom/server

No fs, no net, no env, no process

↓ HTML string

Kotlin writes to disk (trusted)

  1. MDX content is passed into the sandbox as a string
  2. npm packages compile it to HTML inside the sandbox
  3. Only the HTML string comes out
  4. Kotlin code (trusted) writes the file to disk

The npm packages never touch the filesystem directly.

No postinstall Scripts

We don't run npm install. Instead, npm packages are downloaded as tarballs and extracted without executing any scripts:

// Pseudo-code for how packages are fetched
fun fetchPackage(name: String, version: String): Path {
    val tarball = download("https://registry.npmjs.org/$name/-/$name-$version.tgz")
    verify(tarball, expectedChecksum)
    return extract(tarball)  // Just unzip, no scripts
}

This is similar to how pnpm handles packages, but we go further by never executing the JavaScript with system access.

The Trade-offs

This approach has costs:

  • Setup complexity: We maintain Gradle plugins for the build pipeline
  • Limited ecosystem: Can't use npm plugins that need filesystem access (image optimization, etc.)
  • Kotlin learning curve: Components are written in Kotlin, not JavaScript

For us, the trade-off is worth it. We manage embedded devices—a supply chain compromise could push malicious firmware to thousands of machines.

Try It Yourself

If you want similar isolation without our full setup, consider:

  1. Deno: Runs JavaScript with permissions disabled by default
  2. Docker with --network none: Isolate your build in a container
  3. gVisor: Sandboxed container runtime

The key insight: treat npm packages as untrusted input, not trusted code.

What You're Reading

This MDX file was compiled by @mdx-js/mdx running in a sandbox. The React components were rendered by react-dom/server in the same sandbox. Neither package could access anything outside their isolated environment.

The build is hermetic. The execution is sandboxed. The output is static HTML.