Show HN: Execute JavaScript in a WebAssembly QuickJS sandbox

github.com

206 points by sebastianwessel 13 days ago

This TypeScript package allows you to safely execute JavaScript code within a WebAssembly sandbox using the QuickJS engine. Perfect for isolating and running untrusted code securely, it leverages the lightweight and fast QuickJS engine compiled to WebAssembly, providing a robust environment for code execution.

Features

- *Security*: Run untrusted JavaScript code in a safe, isolated environment.

- *File System*: Can mount a virtual file system

- *Custom Node Modules*: Custom node modules are mountable

- *Fetch Client*: Can provide a fetch client to make http(s) calls

- *Test-Runner*: Includes a test runner and chai based `expect`

- *Performance*: Benefit from the lightweight and efficient QuickJS engine.

- *Versatility*: Easily integrate with existing TypeScript projects.

- *Simplicity*: User-friendly API for executing and managing JavaScript code in the sandbox.

jitl 13 days ago

Hi, I’m the author of the underlying quickjs-emscripten runtime library. I like your ergonomic kind of “standard library” for quickjs-emscripten :)

Did you try running in the browser or with a bundler? I think accepting the variant name as a string you pass to import(variantName) dynamically may not play well with Webpack et al.

EDIT: SECURITY WARNING: this library exposes the ability for the guest (untrusted) code to `fetch` with the same cookies as the host `fetch` function. You must not run untrusted code if enabling `fetch`. Library should come with a big blinking warning about what is safe and unsafe to enable when running untrusted code. It’s not a “sandbox” if the sandboxed code can call arbitrary HTTP APIs authenticated as the host context!

The reason quickjs-emscripten is low-level and avoids magic is so I can confidently claim that the APIs it does provide are secure. I generally reject feature requests for magical serialization or easy network/filesystem access because that kind of code is a rich area for security mistakes. When you run untrusted code, you should carefully audit the sandbox itself, but also audit all the code you write to expose APIs to the sandbox.

In this case a comment from an other HN user asking about Fetch cookies tipped me off to the potential security issue.

More reading:

Figma blog posts on plugin sandbox security:

- https://www.figma.com/blog/how-we-built-the-figma-plugin-sys...

- https://www.figma.com/blog/an-update-on-plugin-security/

Quickjs-emscripten README: https://github.com/justjake/quickjs-emscripten

  • silenced_trope 13 days ago

    Would using an iframe (with/without this lib) prevent the fetch issue or is that still a problem there?

    • svieira 12 days ago

      A same-domain iframe would not, but a sandboxed one with the appropriate permissions locked down (or one on another domain) would.

      • jitl 12 days ago

        Even if the fetch doesn’t have first-party cookies/authentication, there’s still there’s things to keep in mind depending on the trust level of the code. For example, is it okay for the untrusted code to access network services on the user’s localhost (like Zoom) or home network (like HomeAssistant, Philips Hue, WiFi router firmware)? These should be blocked by CORS and are sometimes blocked by the browser but it’s something to keep in mind. Phishing or exfiltration of data? Degrading experience or consuming the user’s bandwidth allowance by making too many requests? These concerns may or may not be relevant depending on use-case and the level of lockdown on the iframe. A good strategy is to allow-list the resources the iframe can fetch using content-security-policy, which is well supported in browsers and there’s a browser-level facility for receiving violation logs (although maybe that was phased out?).

        I shared some links to the iframe security options here: https://news.ycombinator.com/item?id=40896873#40904732

AlexErrant 13 days ago

There are many ways to sandbox Javascript, both serverside and browser-side.

Are there any ways to "sandbox" DOM access? I.e. give untrusted 3rd parties access to a DOM element in a predefined spot? AFAIK the only tech that allows for this is iframes, which are unfortunately heavy and slow. I'm writing an app that can host plugins, and unfortunately, I think giving plugins DOM access means they can now literally do literally _anything_.

  • spankalee 13 days ago

    Salesforce does this with a combination of web components, with a patched up ShadowRoot so that code with a reference to the shadow root can't walk into the rest of the document, and a secure evaluator function related to SES (Secure EcmaScript) to limit the globals the untrusted script has access too.

    The secure evaluator is wild. I think this is the heart of it: https://github.com/Agoric/realms-shim/blob/v1.1.0/src/evalua...

    There's also an idea for isolated web components to solve this in the platform: https://github.com/WICG/webcomponents/issues/1002

    • m1el 13 days ago

      Salesforce sandboxing is too easy to escape. Last time I needed to implement some feature for Salesforce, I've encountered 4 different escapes. It was also horrible dev experience.

      • spankalee 13 days ago

        I would love to hear more about that. I'm looking into their approach for a plug-in system myself.

    • cxr 13 days ago

      You can also check out the discussion for Figma's earlier work on their plugin system, which is what inspired jitl (above) to create quickjs-emscripten. Previously:

      How to build a plugin system on the web and also sleep well at night. <https://news.ycombinator.com/item?id=20770105> 2019 August 22. 89 comments.

  • cxr 13 days ago

    The closest thing I know of is Allen Wirfs-Brock's jsmirrors prototype, but he never got to speccing out anything for DOM (and never really intended to as far as I know). Just capabilities for JS-the-programming-system.

    You could look at jsmirrors for inspiration and take a crack at some sort of "dommirrors" yourself, but it's big undertaking. (There's a roundabout way to go about using jsmirrors as-is to kind of achieve what you want, but it's not ergonomic.)

    That being said, giving access to the DOM, even mediated/simulated, is almost certainly not what you really want. Figure out what you _actually_ want to allow the other side to do, and then just give them a capability that lets them do it. (For example, to let them add a button somewhere, you might think you need to give them an anchor point (parent element) where they can insert it and let them use `document.createElement` to make the DOM node that they're going to put there. But you don't actually want that—for them to have access to `document.createElement`, etc. What you want is for them to have an add-button capability. So give them that—go implement `addButton`.)

    Moar: <https://news.ycombinator.com/item?id=30703531#30706060>

    PS: don't listen to anyone who comes along and says that this is what CSP is for. It's not. (If we're being accurate, even for what CSP really is for, it's poorly designed, user-hostile junk and should never have been implemented or extended as far as it has been.) It's dangerous to depend on it.

    • jitl 12 days ago

      Big plus-one to this:

      > That being said, giving access to the DOM, even mediated/simulated, is almost certainly not what you really want. Figure out what you _actually_ want to allow the other side to do, and then just give them a capability that lets them do it. (For example, to let them add a button somewhere, you might think you need to give them an anchor point (parent element) where they can insert it and let them use `document.createElement` to make the DOM node that they're going to put there. But you don't actually want that—for them to have access to `document.createElement`, etc. What you want is for them to have an add-button capability. So give them that—go implement `addButton`.)

      For a plugin model, I’d suggest providing a high-level UI library to add panels & actions rendered by first-party UI components in specific areas which communicate with plugin JS running in quickjs. Many plugins that integrate with the 3rd-party’s own service will also want an iframe for embedding 3rd-party content, so you can provide that as well since iframe is sandboxed and the use-case makes sense. But scripting/plugin code shouldn’t be reading or writing to the DOM, it should be making requests and responding to request from the host application APIs synchronously in-process.

      That’s the way I think about it anyways.

  • jitl 12 days ago

    The only really safe way to approach this would be to give the 3rd party code an off-domain iframe with the sandbox attributes configured. You can still measure the DOM content size from the parent page to resuze the iframe to certain limits to integrate it more seamlessly into your app UI.

    Depending on the level of exposure and trust between your users, you’ll need to watch out for impersonation/phishing and clickjacking attempts in the iframe. Ideally you can lock down the frame so it can’t make any web requests at all (which implies no image loading), which means there’s no way to exfiltrate data from the frame if, for example, they convinced the user to enter their password into a fake password form.

    The main way to restrict what kinds of resources an iframe can request is via content-security-policy, which you can use to turn off all 3rd party images, scripts, etc.

    https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameE...

    https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

    You should also enable these other sandbox attributes and disable access to privacy sensitive DOM APIs like the webcam etc:

    https://developer.mozilla.org/en-US/docs/Web/HTML/Element/if...

    https://developer.mozilla.org/en-US/docs/Web/Security/IFrame...

    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Pe...

  • mattigames 12 days ago

    The only feasible way would be to add a API where they send you the html they want to render -as a string- and you parse it using one of the many libraries to do so, then recreate the Dom based on the parsed data, that way you can whitelist the html elements and the attributes you want to allow, if you want to allow listening to native DOM events that complicates things but is not impossible, you would need something like an API that accepts the name of event (string) and the id of the element that would receive it, you would then listen to that event in the real DOM and replicate such event inside the JS sandbox you may be using (where they must have access to the aforementioned API)

  • austin-cheney 13 days ago

    Off the top of my head the way I would this:

    * On the back end request the third party code and then associate that code with a hash sequence.

    + On the backend dynamically modify the html such that there is a div tag with an id whose value is the hash sequence. Also modify the html such that there is a script tag that requests the third party code from your domain. For tracking purposes you add the hash value to a data attribute on that script tag.

    * On the back end modify that third party code such that all instances of document. and window. are replaced by document.getElementById(hash_value). and all query selectors begin with #hash_value.

    * You would to replace .parentNode in the Element prototype with a custom property that checks for and drops escape from the providdd container.

    Then send the html document to the browser. If the third party code breaks that is ok. The constraints should be communicated to the third party and it’s up to them to test their own code before sending it to your server. All you care about is that their code does not escape the dynamically provided container. Test this regularly on your side to look for security violations.

    Also, this may not work, but it would be fun to experiment with.

    • chmod775 12 days ago

      In the context of this conversation, which is about running untrusted code, this has about a million holes.

      The only way DOM access can become secure is if either browsers add support for sandboxing in such a way, or you have your own sandbox, like OPs, and provide DOM modification APIs within it that go through rigorous validation before you pass anything on to the browser.

      Trying to sandbox with find/replace will never work (unless you replace the entire script with an empty string).

      • austin-cheney 12 days ago

        > this has about a million holes.

        Kind of.

        First of all, the DOM is a global artifact. Browsers do not provide any convention to isolate a section of the DOM tree except for iframes and document fragments and there are limitations to both. iframes are slow and not entirely isolated either. Document fragments are better for security as they are isolated from the document object, but they are designed to be worthless until appended to the document. Document fragments were only created to build multiple DOM trees in parallel without waiting to access the document object, because there is only one document object. These performance concerns are largely irrelevant now because the DOM, even with the extreme slowness of query selector strings, is insanely fast.

        Secondly, this is about running untrusted code. In applications outside the browser this is super scary. However, in the browser this happens just about everywhere all the time. Any JavaScript code that comes into a page not from a domain you own is untrusted. Go to any page and look at the network tab and its common just about everywhere. The only security safeguard to this, besides the browser's single origin policy, is that all security risks are directly transferred to the user in the browser because its not requested or touching the web server.

        With this context in mind talk of security holes is kind of ridiculous to the point of ignorance about how the browser works. As hacky as my suggest is, its still far more secure than how every commercial webpage normally operates.

        • chmod775 12 days ago

          > Kind of.

          This is JS code that will write "Hello!" to the body tag, bypassing protections that rely on find/replace: https://gist.githubusercontent.com/laino/8d2676f8fd6fe0de19d...

          Another one that uses eval, which may be disabled by CSP on some pages: https://gist.githubusercontent.com/laino/316843234f5da5073bd...

          The point is that find/replace will never work with a dynamic language like JS.

          > First of all, the DOM is a global artifact. Browsers do not provide any convention to isolate a section of the DOM tree except for iframes and document fragments and there are limitations to both.

          There's also shadow DOM which allows you to encapsulate things on a page.

          > However, in the browser this happens just about everywhere all the time.

          And this whole exercise is about making that secure, which is what OPs sandbox can do for you. Code in such sandboxes can only interact with whatever you explicitly give them access to, which is called whitelisting, whereas your approach is trivially circumventable blacklisting (the code can do anything you don't explicitly prevent).

          If all you expose to code in a sandbox is your own DOM modification utilities that perform rigorous validation, then absent of any security holes, that's what the code running in the sandbox will be able to do. If you decide all it gets to do is create up to 10 div tags with a custom text and color, then that's it.

    • dawnerd 13 days ago

      That seems like it’s pretty fragile though. I’d be really worried about all the weird edge cases

      • jitl 12 days ago

        You can easily escape this by traversing a DOM node’s parent pointer.

        • austin-cheney 12 days ago

          Not at all. So, yes, depending upon the implementation there is a parent pointer tree which binds node hierarchy. This is not really how the DOM works though and not what's exposed via API. Typically the nodes are objects in memory and point to each other via static relational reference. These references are exposed to the API, like: parentNode, nextSibling, childNodes.

          Under the hood deep in the binary might the parentNode make use of a parent pointer tree to map between the node instance in memory? Again, that depends upon the implementation and is entirely irrelevant to the executing JavaScript which is isolated from that layer.

          • jitl 12 days ago

            I mean "parent pointer" in a generic sense. There’s more parent direction pointers available in addition to parentNode: parentElement, ownerDocument, offsetParent. There’s just loads and loads of ways to navigate around the DOM. You can fire a custom event and watch as it bubbles out of the “sandbox” with `myEvent.currentTarget`. You could build a new script to eval bit-by-bit and then attach it with a <script> tag (can disable that with CSP though).

            I wouldn't trust security based on a deny-list approach, especially when it comes to an API surface area as complex as the DOM, where the platform can roll out new APIs before you can update your deny-list.

frabjoused 13 days ago

Coincidentally I was trying out quickjs last week and ultimately ended up settling on isolated-vm instead as both met our security contrasts, however isolated-vm ended up being far more performant in terms of setup, teardown and eval execution overhead.

emurlin 13 days ago

Interesting approach! As an author of another JS sandbox library[1] that uses workers for isolation plus some JS environment sanitisation techniques, I think that interpreting JS (so, JS-in-JS, or as in this case, JS-in-WASM) gives you the highest level of isolation, and also doesn't directly expose you to bugs in the host JS virtual machine itself. Since you're targeting Node, this is perhaps even more important because (some newer developments notwithstanding) Node.js doesn't really seem to have been designed with isolation and sandboxing in mind (unlike, say, Deno).

From the API, I don't see if `createRuntime` allows you to define calls to the host environment (other than for `fetch`). This would be quite a useful feature, especially because you could use it to restrict communication with the outside world in a controlled way, without it being an all-or-nothing proposition.

Likewise, it doesn't seem to support the browser (at least, running a quick check with esm.sh). I think that that could be a useful feature too.

I'll run some tests as I'm curious what the overhead is in this case, but like I said, this sounds like a pretty solid approach.

[1] @exact-realty/lot

  • jitl 13 days ago

    I’m the author of the underlying quickjs-emscripten library. It supports the browser (specifically tested with ESM.sh), as well as Cloudflare Workers, NodeJS, Deno: https://github.com/justjake/quickjs-emscripten?tab=readme-ov...

    It has APIs for exposing host functions, calling guest functions, custom module loaders, etc: https://github.com/justjake/quickjs-emscripten?tab=readme-ov...

    API docs for newFunction: https://github.com/justjake/quickjs-emscripten/blob/main/doc...

    • brigadier132 13 days ago

      Wow cloudflare workers support is actually super cool. How does it limit memory usage?

      • jitl 13 days ago

        The quickjs interpreter C code counts the bytes it's allocated, and refuses to allocate more if over the limit. It decrements by the allocation size when freed. This malloc function is used everywhere the interpreter allocates memory:

            static void *js_def_malloc(JSMallocState *s, size_t size)
            {
                void *ptr;
            
                /* Do not allocate zero bytes: behavior is platform dependent */
                assert(size != 0);
            
                if (unlikely(s->malloc_size + size > s->malloc_limit))
                    return NULL;
            
                ptr = malloc(size);
                if (!ptr)
                    return NULL;
            
                s->malloc_count++;
                s->malloc_size += js_def_malloc_usable_size(ptr) + MALLOC_OVERHEAD;
                return ptr;
            }
FpUser 13 days ago

CPU got too fast so let's run interpreter inside interpreter.

jitl 13 days ago

i wouldn’t say “performance” as an advantage of running JS in QuickJS. QuickJS isn’t competitive at all with the host JS VM, although I guess it’s faster than older C interpreters, or an interpreter implemented in JavaScript.

  • math_dandy 13 days ago

    I suppose you get performance benefits if the the time it takes to start up a nodejs process dominates the execution time of the script. This is probably the case for a decent proportion of “serverless function” type scripts.

    • jitl 13 days ago

      This library expects to run inside a Javascript runtime like NodeJS, so you're always going to pay for the enclosing Javascript runtime to start.

      • ijustlovemath 13 days ago

        Not true, QuickJS works completely standalone. I've used it to compile NodeJS libraries into standalone libraries that other C libraries can call, even on a system without a NodeJS install.

        • jitl 13 days ago

          This post is about a quickjs-in-node library that wraps the quickjs C library, not quickjs itself. My original comment is responding to a comment by the author of the library.

    • throwitaway1123 13 days ago

      Yup, AWS actually created a JS runtime called LLRT (Low Latency Runtime) based on QuickJS exactly for this purpose (reducing Lambda function cold start time). The Syntax podcast just released an episode with one of the developers behind LLRT.

      • intelVISA 12 days ago

        'created' is VERY generous wording

        • throwitaway1123 12 days ago

          I don't want to take any credit away from Fabrice Bellard and his work on QuickJS. All of the serverless QuickJS derivatives like Shopify's Javy and AWS's LLRT owe a debt of gratitude to Bellard. They're all very thin wrappers over QuickJS (which is by design obviously in order to reduce cold start times). Use whichever verb you prefer in place of 'created'.

leohart 13 days ago

This is awesome. With this, I would be able to run JS code that my user provides. I have been looking for a way to bundle my user Typescript code using a bundler in a sandbox environment. Any recommendation on ways to run a bundler (webpack/...) in QuickJS?

  • idle_zealot 13 days ago

    I don't know about using QJS, but if you want to run a bundler in the browser that sounds like the sort of thing that WebContainers[1] were built for.

    [1]: https://webcontainers.io/

brigadier132 13 days ago

Very cool. Since this is compiled to wasm can this run in the browser? It would be interesting if it could and still make fetch requests without attaching cookies to the request.

aitchnyu 12 days ago

In a previous job, I got way too many "segmentation faults" from Quickjs-emscripten and silent errors and project was put on hold. I will probably use an engine which works correctly on more programs and has an officially blessed wasm bundle if I had to do it again.

remram 13 days ago

I thought you could safely sandbox code via an iframe, though I'm not certain. Of course using your own interpreter might allow for more features, like tighter timeouts, custom APIs, etc.

  • firtoz 13 days ago

    I would like to know more about this too, especially when you add service workers into the mix.

    I am currently using an iframe that accepts code with a window message and it can evaluate the input code and respond with a window message back, which works quite well, but I am unsure if there may be holes out of the sandbox

    For more complicated things for example package dependencies I tried parsing them with Babel then producing an import map with cdns (esm.sh) but in some cases the cdnified versions don't work well

    So I used stackblitz which kinda works well but has some issues in non secure contexts

    So I ended up coding a little web server that takes in the code and dependencies (package.json) and does a vite build inside a docker container and sends the output back, it's working decently but can be slow sometimes

    Doing a build completely on the client would be great, which is kinda what stackblitz does

  • hackcasual 13 days ago

    IFrames are over-permissioned. For example an iFrame can exfiltrate data to a 3rd party

EGreg 13 days ago

How can I make sure the iframe cannot make any requests to any servers, including WebSockets?

Also what happens if the code has infinite loops? Is there an ability to pause execution? That woukd be helpful

anonymousd3vil 13 days ago

so we come to a full circle

  • TheRealPomax 13 days ago

    Spiral. Not circle. Having something run in itself is as old as the need to safely run something in order to see if it's going to blow up or not, though. If people aren't trying to write things so your general use programming environment can virtualize itself, it's basically not used seriously enough yet.

  • degurechaff 13 days ago

    now we just need wasm intepreter in js. so we can run this package to run javascript to run wasm intepreter and so on..

WatchDog 13 days ago

I had started working on something very similar to this, a higher level wrapper for quickjs-emscripten.

quickjs-emscripten is great, but it's API is deliberately very close to quickjs's C API. It can be quite difficult to use directly, and implementing support for loading libraries is non-trivial, especially if any of those libraries depend on certain nodejs or browser APIs.

Implementing support for `require()` is tricky, because it's a blocking API, so doing any async IO to fetch module code is not possible unless you either:

- Use the asyncifyed version of quickjs-emscripten(slower and more difficult to use) - Use blocking IO to load modules(not ideal). - Pre-load all module files into an in-memory filesystem(which is what is sounds like this lib is doing).

I haven't looked much into how the quickjs-emscripten-sync library works exactly, but automatic syncronization of host and guest functions, seems like it could be a big attack surface, and I worry that it might be possible to escape the sandbox with it somehow.

  • circuit10 12 days ago

    Would https://v8.dev/blog/jspi help? Looks like it's behind a flag for now though

    • WatchDog 12 days ago

      Interesting, yeah possibly, I wonder what the performance would be like compared to asyncified code.

      • circuit10 11 days ago

        There is a section on the page about the performance, it looks like there shouldn’t be much overhead

waldrews 13 days ago

Yes! Now, we just have to run this inside a browser, which will run inside a container, which will run inside a VM, which will run on an emulation layer...

cal85 12 days ago

Does this support running in a browser? I can’t find any mention of supported environments in the docs.

bluelightning2k 13 days ago

Oh very nice! Guessing there isn't support for node modules but still very cool.

djaouen 13 days ago

If you’re on wasm, why not just use Elixir and be done with it?

devwastaken 13 days ago

You cannot throw things into wasm and call it safe. It is wholly irresponsible. You need to do the work to ensure it's safe to a theory and in practice.

  • brigadier132 13 days ago

    quickjs with wasm can be considered secure before different system apis are introduced

owenpalmer 13 days ago

This is an xkcd waiting to happen

jojobas 13 days ago

I thought the whole purpose of WebAssembly was not to execute any JavaScript.

  • brigadier132 13 days ago

    Well you'd be mistaken. The point of WebAssembly is to run any language that compiles to WebAssembly in a secure sandbox.

  • anon115 13 days ago

    moew meoew moewm meow moew meow XDD the point of wasm is to play video games on the browser.

  • Muromec 13 days ago

    [flagged]

    • mpalmer 13 days ago

      Making sandboxed execution about your anticapitalist politics, talk about layers of indirection! You're right, it makes for inefficient discourse as well.