· 5 min read ·

Running Untrusted JavaScript on the JVM: GraalVM's Layered Sandbox Model

Source: lobsters

The problem of running untrusted JavaScript safely has a long and embarrassing history. Node.js ships a vm module that has carried a disclaimer in the docs for years: it is not a security boundary. Anyone who has tried to build a plugin system or formula engine on top of it has learned this the hard way. The vm2 library tried to paper over the gaps for nearly a decade before a series of critical remote code execution vulnerabilities in 2022 and 2023 forced the maintainer to abandon the project entirely. The ecosystem moved on to isolated-vm, which gives you real V8 isolates but requires you to be running in a Node.js process to begin with.

GraalVM takes a different path. Its JavaScript sandboxing documentation describes an approach built into the runtime itself, using the Polyglot Embedder API to create isolated JavaScript execution contexts from Java. The isolation is not bolted on after the fact; it comes from the JVM’s own security model combined with explicit capability grants at context creation time.

The Polyglot Embedder API

When you embed JavaScript in a Java application using GraalVM, you work with org.graalvm.polyglot.Context. The Context object represents a single execution environment for one or more languages. By default, a freshly created context is fairly locked down, but the newBuilder() pattern lets you grant capabilities explicitly, or use a SandboxPolicy to establish a baseline.

import org.graalvm.polyglot.*;
import org.graalvm.polyglot.io.IOAccess;

try (Context context = Context.newBuilder("js")
        .allowIO(IOAccess.NONE)
        .allowHostAccess(HostAccess.NONE)
        .allowCreateThread(false)
        .allowNativeAccess(false)
        .allowEnvironmentAccess(EnvironmentAccess.NONE)
        .build()) {
    Value result = context.eval("js", "2 ** 10");
    System.out.println(result.asInt()); // 1024
}

This is already more principled than anything you can do in Node.js. The context has no filesystem access, no ability to spawn threads, no visibility into environment variables, and no way to call back into Java host objects. The code runs, produces a value, and that value crosses the boundary through a typed Value wrapper.

The SandboxPolicy Hierarchy

Recent GraalVM releases formalize this into a SandboxPolicy enum with four levels: TRUSTED, CONSTRAINED, ISOLATED, and UNTRUSTED. Rather than forcing you to remember which individual permissions to revoke, you pick the appropriate policy and the runtime enforces a coherent set of restrictions.

TRUSTED is the default. It imposes nothing. CONSTRAINED restricts the most dangerous capabilities while still allowing some host interaction. ISOLATED goes further, preventing the guest language from observing the host environment at all. UNTRUSTED is the maximum isolation level, appropriate for executing code you have no control over whatsoever.

try (Context context = Context.newBuilder("js")
        .sandbox(SandboxPolicy.UNTRUSTED)
        .build()) {
    // JavaScript code here cannot touch the filesystem, environment,
    // host classes, threads, or native code
    Value result = context.eval("js", "[1,2,3].reduce((a,b) => a+b, 0)");
    System.out.println(result.asInt()); // 6
}

The policy approach matters because security configurations tend to erode over time when they are just a checklist of options. One developer adds a capability for a legitimate reason, another sees the pattern and copies it, and eventually the sandbox is more hole than wall. A named policy at least makes the intent visible in code review.

Controlled Host Exposure

The most interesting use case for embedding JavaScript is not pure isolation but controlled exposure: you want the guest code to call specific Java APIs you provide while being unable to reach anything else. The HostAccess builder handles this.

HostAccess hostAccess = HostAccess.newBuilder()
    .allowPublicAccess(false)
    .allowImplementationsAnnotatedBy(FunctionalInterface.class)
    .allowArrayAccess(true)
    .allowListAccess(true)
    .build();

try (Context context = Context.newBuilder("js")
        .sandbox(SandboxPolicy.ISOLATED)
        .allowHostAccess(hostAccess)
        .build()) {
    // Inject only what you want the script to see
    context.getBindings("js").putMember("fetchPrice",
        (Function<String, Double>) ticker -> priceService.lookup(ticker));
    
    Value result = context.eval("js",
        "fetchPrice('AAPL') * 100");
}

The JavaScript code can call fetchPrice because you explicitly placed it in the bindings. It cannot reach priceService directly, cannot enumerate the Java class hierarchy, and cannot use reflection to escape. The boundary is enforced by the runtime, not by hoping the guest code plays fair.

This pattern suits formula engines, workflow scripting, and user-defined transformations in data pipelines well. The guest language gets exactly the surface area you define, nothing more.

Resource Limits

Context isolation controls what code can access, but it does not stop a malicious or buggy script from consuming unbounded CPU or memory. A simple while(true){} loop will deadlock a context regardless of how locked down its permissions are.

Oracle GraalVM (the commercial distribution) provides the ResourceLimits API and a set of sandbox.* options for this:

ResourceLimits limits = ResourceLimits.newBuilder()
    .statementLimit(500_000, null)
    .build();

try (Context context = Context.newBuilder("js")
        .sandbox(SandboxPolicy.UNTRUSTED)
        .resourceLimits(limits)
        .option("sandbox.MaxHeapMemory", "10mb")
        .option("sandbox.MaxCPUTime", "5s")
        .allowExperimentalOptions(true)
        .build()) {
    // eval user-provided code
}

The statementLimit stops infinite loops at the bytecode level by counting executed statements, similar to how some SQL engines limit query cost. The sandbox.MaxCPUTime and sandbox.MaxHeapMemory options are more expensive to enforce because they require the runtime to track resource consumption continuously, which is why they remain an Oracle GraalVM feature rather than something available in the community edition.

For the community edition, statementLimit via ResourceLimits covers the most common attack surface. CPU time limits require the enterprise distribution or a separate thread with an interrupt.

Why This Is Different From Node.js Approaches

The fundamental difference is where the isolation lives. In Node.js, vm.runInNewContext() creates a new V8 context but shares the same V8 isolate with your host process. Escaping to the outer context is a JavaScript-level puzzle, and the JS specification does not make that impossible. Most vm2 exploits followed a similar pattern: find an object that crossed the context boundary, climb through its prototype chain or constructor, and reach Function in the outer context.

isolated-vm solves this by actually using separate V8 isolates, which is the right abstraction. GraalVM solves it differently: the execution environment is a JVM-managed Context object, and access to the host environment requires passing through Java type barriers that the JVM enforces. There is no prototype chain that leads back to arbitrary Java code; the only Java objects visible to JavaScript are those you explicitly pass in via typed interfaces.

The tradeoff is that GraalVM is a heavier runtime than V8. Context creation is not instantaneous, and if you are building something that needs to spin up thousands of short-lived sandboxes per second, the startup overhead matters. For use cases where you create a context once and evaluate many scripts against it, or where contexts are pooled, this is less of a concern.

Practical Fit

The use cases where GraalVM’s sandbox model shines are Java-based applications that want to offer extensibility through scripting: business rule engines, ETL pipelines with user-defined transformations, server-side formula evaluation in SaaS products, or plugin systems where the host application is already on the JVM. If you are building in Java or Kotlin and you need to execute user-provided JavaScript, this is the most principled option available.

For Node.js applications, isolated-vm remains the practical choice. GraalVM is not a drop-in for a Node.js process, and running the full JVM just for sandboxed script execution adds considerable weight to a deployment.

The security guide is worth reading carefully because it documents which policies enforce which restrictions and notes where community edition ends and Oracle GraalVM begins. The distinction matters when you are budgeting for a production deployment. Statement limits are free; CPU time and heap limits cost a license.

What the GraalVM team has built is a coherent model for untrusted code execution that treats isolation as a first-class runtime concern rather than a user-space library layered on top of an engine that was never designed for it. That is a meaningful improvement over where the Node.js ecosystem has been, and it deserves more attention from developers building extensible JVM applications.

Was this interesting?