Running untrusted JavaScript on your own infrastructure

Sooner or later a platform ends up executing code it didn’t write. A partner ships an integration. A user saves an automation. A model generates a script. The code has to run somewhere, and the practical answer is usually your own servers. At ZeroClick I led a system where partner-authored integration code ran on our infrastructure in a Node vm sandbox. This article is the list of things that went wrong, what fixed them, and the checklist I now apply to any execution layer before it faces partners or agents.

A VM context isolates scope, not capability

Node’s vm module gives the code a fresh global namespace. That is the entire guarantee. It restricts which names the code can see, not what the code can do. Whatever you place on the sandbox global is the attack surface, and everything else the process can reach is reachable through those objects. Our sandbox injected four globals: an access token, a context object, provisioned credentials, and a prompt function for asking a human a question mid-run. That short list sounds contained. The problem was the fifth thing we handed over.

The network is the biggest capability you hand over

Integration code needs to make HTTP calls — that’s the whole point. Our first version gave the sandbox the platform’s own fetch. That meant sandboxed code could reach anything the host could reach: internal services, and the cloud metadata endpoint. On a host with workload identity enabled, the metadata endpoint hands over service-account credentials to anyone who asks from inside. A sandboxed script three lines long could have walked out with a token for our own cloud project. We found this in our own review and replaced raw fetch with a wrapper that does three things:

  • Resolves DNS first and validates the resolved address. A hostname allowlist alone loses to DNS rebinding — the name resolves to a public address when you check it and a private one when you connect. Validate the IP you are actually about to dial.
  • Blocks private and link-local ranges plus metadata hostnames. RFC 1918 space, 169.254.0.0/16, and the metadata hostname by name.
  • Enforces HTTPS. No plaintext egress from the sandbox.

Logs are an exfiltration channel

We piped the sandbox’s console output into our platform logger so partners could debug. Console output carries whatever the sandboxed code chooses to print — including credentials it legitimately holds during a run. Those lines flow into log aggregation, dashboards, and whoever has read access to any of it. Our first fix was a redaction pass over known-sensitive values. We replaced it one day later by disabling the sandbox console in production entirely and keeping it only in development. The lesson generalizes: you cannot enumerate what an adversary will print, so control the channel rather than the content.

Error reporting leaks the same way. When a layer that handles credentials reports a failure, log the names of the variables involved, never their values. “Token exchange failed for field refreshToken” debugs just as well as the token itself, and survives a log breach.

Time and resources

Every run gets a wall-clock timeout — ours was five minutes. The subtle part is deciding what resets it. Our sandboxes could suspend mid-run to ask a human a question, and answering re-armed the timeout. A sandbox waiting on a person is not a runaway loop, and killing it for human thinking time punishes the exact flows you built the system for. Only genuine execution should burn the clock.

What a same-process VM cannot give you is a memory or CPU ceiling. A tight loop or a large allocation takes down the whole process, timeout or not. That is the line where you move to a worker thread, a separate process you can kill, or a microVM. We stayed same-process because our code came from contracted partners, not anonymous users — a threat-model call, and one you should make explicitly rather than by default.

The execution contract

Untrusted code should conform to your shape, not the other way around. Our integrations were a fixed set of named lifecycle functions with a defined callback shape, explicit result and error conventions, and the four injected globals as the only API. Nothing ambient — no require, no process, no filesystem. The discipline that pays off: enumerate exactly what you inject, and treat that list as the security review. If the list fits on one screen, a reviewer can reason about it. If it doesn’t, nobody can.

Secrets

Pass the sandbox a scoped, short-lived token for the one job at hand, never long-lived platform credentials. And retrieve credentials server-side — never route them through the caller. In our system, instructions and results passed through a browser context on the way to the model; anything sensitive that touched that hop could be tampered with or read by an XSS on the page. The credential exchange happened server to server, always.

The boundary around the sandbox leaks too

The sandbox itself is only part of the surface. Any endpoint that returns results from sandboxed execution needs its own authentication. We had a status endpoint that a browser polled to show progress. It had been made public to make polling easy — and it still returned provisioned credentials in its payload. Anyone with a guessable identifier could fetch another tenant’s secrets. The fix had two parts: the status path never returns secrets, full stop, and results are fetched separately with a one-time, caller-bound token that is consumed atomically on use. Worth noting: our first attempt at “one-time” minted a fresh token on every poll, which meant an unbounded supply of valid tokens and defeated the point. One-time means one token, used once, gone.

The checklist

  1. List every object you inject into the sandbox. That list is your attack surface — review it, not the sandboxed code.
  2. Wrap all network access: resolve DNS, validate the resolved IP, block private and link-local ranges and metadata hostnames, require HTTPS.
  3. Disable sandbox console output in production. Control the channel, not the content.
  4. In error reports from credential-handling code, log variable names, not values.
  5. Set a wall-clock timeout and decide deliberately what re-arms it.
  6. Know your resource-ceiling story. Same-process means none — decide whether your threat model tolerates that.
  7. Fix the entry point, callback shape, and error conventions. Make the code fit your contract.
  8. Scope every token to the run. Exchange credentials server-side, never through the caller.
  9. Authenticate every endpoint that touches execution results. Status paths return status, never secrets.

None of these bugs required a sophisticated attacker. Each one was an ordinary engineering decision — hand over fetch, pipe the console, open the status endpoint — that was reasonable in isolation and wrong at the boundary. We caught them in-house before anyone else did. The way to get that outcome is to review the boundary as its own artifact, before the first line of partner code arrives.

Questions clients ask

Is Node’s vm module safe for untrusted code?

Not by itself. A vm context isolates scope — the code gets a fresh global namespace — but not capability. Anything you place on the sandbox global, and anything reachable through those objects, is available to the code. Node’s own documentation says the module is not a security mechanism. It becomes workable only when you inject a small, wrapped API surface, add a wall-clock timeout, and accept that it gives you no memory or CPU ceiling.

What should I use instead of the vm module?

It depends on your threat model. For partner code you review and contract with, a same-process VM with a wrapped fetch, no ambient globals, and a timeout can be acceptable. If you need memory or CPU limits, move to a worker thread or a separate process you can kill. For fully adversarial code — anonymous users, model-generated scripts run at scale — use a microVM or container with its own kernel-level isolation and network policy. The injection-surface discipline is the same at every tier; the tiers differ in what happens when the code misbehaves.

How do I stop sandboxed code from calling my internal services?

Never hand the sandbox your platform’s own fetch. Wrap it: resolve DNS yourself, validate the resolved IP address before connecting, and reject private ranges, link-local ranges, and cloud metadata hostnames. Validating the hostname alone is not enough — DNS rebinding lets a hostname resolve to a public address when you check it and a private one when you connect. Enforce HTTPS while you’re at it.

How do I let partners test their code safely?

Give them the exact sandbox that runs in production, pointed at test credentials. Keep developer conveniences — console output, verbose errors — enabled in the development environment only, and strip them in production. If the test environment behaves differently from production in its injected API surface, partners will ship code that breaks, and you will be tempted to loosen production to match.

Exposing an execution layer to partners or agents?

Before partner code or agent traffic touches your infrastructure, have someone who has done this walk your integration surface. My MCP server audit is $2,000, takes a week, and covers the execution boundary: what you inject, what can be reached through it, and what leaves through your logs and endpoints. The fee is credited toward any follow-on work.

Book a free intro callEmail me insteadI personally reply within one business day. · Currently booking September 2026.