Node.js packages, NPM post-install scripts horrify me.
So much of “traditional” security relies on if an application can get root, change your system etc… to say absolutely nothing about what’s sitting in your $HOME
directory.
My SSH keys? My downloads? My private notes?
At any time—any of the bazillion packages NPM install could be malicious, and secretly exfiltrate your data. DYLD_INSERT_LIBRARIES
something, put something in your .bashrc
, whatever.
Another JavaScript runtime—Deno tries to solve this problem:
> deno run --allow-run runner.ts
error: Uncaught PermissionDenied: Requires env access to "HOME", run again with the --allow-env flag
const privateKey = await Deno.readFile(Deno.env.get("HOME") + "/.ssh/id_ed25519");
By default, access to most system I/O is denied. There are some I/O operations that are allowed in a limited capacity, even by default. These are described below.
To enable these operations, the user must explicitly grant permission to the Deno runtime. This is done by passing the --allow-read
, --allow-write
, --allow-net
, --allow-env
, and --allow-run
flags to the deno command.
Digging deeper
As far as I could tell—Deno’s approach is only gated by an application-specific sandbox. The files to be read/denied are checked against an allowlist in their Rust code, if it’s there it’s allowed, otherwise it’s denied.
Deno makes the assumption its JavaScript engine, V8, and it’s application code is perfect—free of memory bugs. If this assumption is violated, then you can break the sandbox. Furthermore, if you allow calling a subcommand like ESBuild, you also break the sandbox.
This is inconsistent with other projects where V8 is used—Chromium’s “Sandbox” takes the assumption their application sandbox has already been escaped.
Assume sandboxed code is malicious code: For threat-modeling purposes, we consider the sandbox compromised (that is, running malicious code) once the execution path reaches past a few early calls in the main()
function. In practice, it could happen as soon as the first external input is accepted, or right before the main loop is entered.
How they accomplish this is a command in macOS named sandbox-exec
, a command that allows us at the OS level to restrict what a process can read, write, and access through the network.
The mechanism is not documented outside of Apple, is technically deprecated (man sandbox-exec
), but even so is used by Chromium, Firefox, and Bazel. Profiles are defined using some sort of Scheme variant.
How these have been documented, then, is by analyzing ones internal to macOS—try picking an example in /System/Library/Sandbox/Profiles
:
;; cat /System/Library/Sandbox/Profiles/bsd.sb
(version 1)
(debug deny)
(import "system.sb")
;; allow processes to traverse symlinks
(allow file-read-metadata)
(allow file-read-data file-write-data
(regex
; Allow files accessed by system dylibs and frameworks
#"/\.CFUserTextEncoding$"
#"^/usr/share/nls/"
#"^/usr/share/zoneinfo /var/db/timezone/zoneinfo/"
))
(allow ipc-posix-shm (ipc-posix-name "apple.shm.notification_center")) ; Libnotify
(allow signal (target self))
Neat! Then run it with sandbox-exec -f /System/Library/Sandbox/Profiles/bsd.sb node index.js
, although don’t expect it to do much without modification.
Tying this together
Please let me sandbox Node.js. Please let me sandbox NPM. While Deno is a great start, their sandbox is insufficient relying only on application-level policy—true sandboxing needs OS-level sandboxing.
Deno needs OS-level sandboxing because they cannot guarantee the correctness of their system, something even Google admits for V8:
The key to security is understanding: we can only truly secure a system if we fully understand its behaviors with respect to the combination of all possible inputs in all possible states. For a codebase as large and diverse as Chromium, reasoning about the combined behavior of all its parts is nearly impossible.
On macOS, this can be accomplished using sandbox-exec
.
I’d really love to see Deno ship with sandbox-exec
support, and for the permissions Deno has developed to permeate Node.js/NPM writ large.