How This Blog Is Made
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/credentialsor~/.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:
| Package | Downloads | What Happened |
|---|---|---|
| event-stream (2018) | 2M/week | Stole Bitcoin from Copay wallets by injecting code into the build |
| ua-parser-js (2021) | 8M/week | Installed crypto miners and password stealers via postinstall |
| node-ipc (2022) | 1M/week | Wiped files based on geolocation; affected Vue CLI users |
| colors + faker (2022) | 20M/week | Maintainer 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/serverNo fs, no net, no env, no process
Kotlin writes to disk (trusted)
- MDX content is passed into the sandbox as a string
- npm packages compile it to HTML inside the sandbox
- Only the HTML string comes out
- 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:
- Deno: Runs JavaScript with permissions disabled by default
- Docker with
--network none: Isolate your build in a container - 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.