Iâm not a fan of Denoâs security model (2024)
Iâm not a fan of Denoâs security model
this has been bothering me for a while now, so I want to write down my thoughts so I can get it out of my head and move on. weâre gonna talk about how Deno approaches security, specifically with its permissions system, and why I think this approach is fundamentally insecure and misleading.
wait, whatâs Deno?
Deno is a language runtime for JavaScript. it consists of a standard library, a package manager, a TypeScript transpiler and a bunch of other stuff thatâs not important for our story. itâs not a JavaScript implementation, so it does not interpret or compile JavaScript, it reuses V8 for that.
one of the flagship features of Deno is its permission system (itâs under the âSecure by defaultâ header on their landing page). the idea is simple: as JS code running in Deno needs the standard library to access the outside world, Deno can choose which accesses it allows or disallows, so when you run
shelldeno run --allow-read=/tmp/dir --allow-run=uname code.ts
the standard library will only allow
code.tsto read
/tmp/dirand run
uname.
additionally, a program can request permissions at runtime, so code like
typescriptconst r = { name: "read", path: "/tmp/dir" } as const;
await Deno.permissions.request(r);will display a nice little prompt in the terminal:
â â ïž Deno requests read access to "/tmp/dir". â Requested by `Deno.permissions.request()` API. â Run again with --allow-read to bypass this prompt. â Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all read permissions) >
and you can grant or refuse it.
(aside: I really miss this feature in browser extensions. currently, if your extension needs potential access to any website, it needs to request actual access to all the websites, which is less than ideal)
if you like puzzles, this is the point where you can stop and think about what could go wrong with this system.
the prompts problem
of the two specific issues I want to point out this is the less severe one. weâll discuss it first because this was the first thing I noticed and it kinda immediately irked me when I first read about this.
(if youâre only interested in the more juicy part, you can skip to the next section).
so, this nice little prompt is displayed in the terminal. itâs not, like, a PolKit GUI window or anything, itâs just text on stdout waiting for input from stdin. this is a wild thing to do.
you see, terminal has a somewhat weird API. most of the commands / requests application can send to the terminal are sent in-band, by printing them to stdout. terminal can then reply by sending data to stdin. as Deno allows programs to print arbitrary data to stdout and read from stdin without requiring any permissions or doing any filtering, malicious code can send arbitrary commands to the terminal. itâs usually okay, because terminals wonât, like, execute shell commands just because stdout told them to, youâre supposed to be able to freely
catfiles without risking arbitrary code execution. itâs markedly less okay if you allow malicious code to do whatever with the terminal and then print a prompt on the same exact terminal.
you could, like, hide the prompt by messing with terminal colors. (this one got closed in 2026 because the permission prompt is âpartially visibleâ (you can see an emoji) and âAI agents tend toâ change terminal colors, so thatâs a âvalid use-caseâ)
or trick the user into pre-feeding approval into stdin.
or trick the terminal into feeding approval into stdin as a response to some request. (terminal wonât ever send Enter to stdin to avoid executing commands, but âpress Enter to continueâ is common enough to not be suspicious; the user never sees the actual prompt). hereâs an asciicast of that, I think it looks quite cool.
the specific issue on the screencast is long patched; the issue with terminal colors is not. that doesnât really matter because the real issue is using an attacker-controlled channel for security-sensitive information. itâs like executing attacker-controlled JS code on a webpage and then asking for password on the same page. you canât know that the page is in a safe state, so you canât trust any input.
even if Deno manages to patch every single issue with the âstandardâ escape codes, you never know which non-standard codes even exist out there. many terminals have some, itâs impossible to predict how they will interact with your prompt.
(aside: how come sudo can do it then? the difference is that sudo asks for the password first, before running any other code. to trick sudo that way youâd need to fuck up terminal state before you even type
sudo, and do it quietly enough that you donât notice it while you type it. if some terminal allows it, Iâd consider it a bug in the terminal)
the --allow-run
problem
so, thereâs a weird thing with this command I wrote above (Iâll copy it here for reference):
shelldeno run --allow-read=/tmp/dir --allow-run=uname code.ts
what does
--allow-run=unameeven mean? itâs not like programs have canonical names, itâs not
org.gnu.coreutils.uname, itâs not even
/nix/store/8k1dmzr3gwzqdgw45nzj76902ca7g1is-coreutils-full-9.5/bin/uname. itâs just a shortcut for âlook at all the directories in
PATHone-by-one to see if you can find
unamethereâ.
you can set environment variables for the child process without needing any extra permissions, so what if we just
$ cat t.ts
const command = new Deno.Command("uname", {
// my current working directory, can be anything
env: { 'PATH': '/tmp/tmp.7ofmKaNW83' },
});
const { code, stdout, stderr } = command.outputSync();
console.log(new TextDecoder().decode(stdout));
$ cat uname
#!/bin/sh
printf lol
$ deno run --allow-run=uname t.ts
lolyeah.
once again, we have a pretty and convenient interface that doesnât really make sense from the security standpoint. it would be nice if programs were uniquely identified by their names, but theyâre not, and pretending they are leads to security holes.
funnily enough, I didnât even think that this issue would be there when I poked at Deno for the first time, so I havenât reported it until now (spoiler alert: this is a comment on a GitHub issue that I created for the problem Iâll explain below).
so letâs imagine we fixed that. itâs not that hard, we can force
PATHto be whatever it was when the runtime started, or always require fully-qualified paths, or prompt with a fully-qualified path before running. are we safe now?
lol no of course weâre not. while
PATHdecides which executable gets run, dynamically-linked executables (so, like, most executables) require dynamic loader to actually start. dynamic loader is a program that, well, loads the executable into memory, finds its dependencies and does a bunch of other fun stuff we wonât get into. the important part is that dynamic loader is configurable via environment variables. you can probably see where this is going now.
LD_PRELOADinstructs the dynamic loader to load specific dynamic library when loading executables. dynamic libraries can execute arbitrary code on load. we can just pass
LD_PRELOADand weâve completely escaped the sandbox once again:
$ cat t.c
#include <stdio.h>
// ask dynamic loader to run this function on load time pretty please
__attribute__((constructor)) void lol() {
puts("lol");
}
$ cat t.ts
const command = new Deno.Command("uname", {
// ask dynamic loader to load our evil library
env: { 'LD_PRELOAD': '/tmp/tmp.7ofmKaNW83/liblol.so' },
});
const { code, stdout, stderr } = command.outputSync();
console.log(new TextDecoder().decode(stdout));
$ gcc -fPIC -shared t.c -o liblol.so
$ # that's the real uname now!
$ deno run --allow-run=uname t.ts
lol
LinuxI reported this some time ago and now they patched it by disallowing to set environment variables starting with
LD_. this, once again, fixes a specific issue while ignoring the larger problem: âpermission to run a single commandâ is mostly not a useful concept. (you can do secure interfaces by allowing to run a single command, like git-over-SSH does, but generally both the environment and the command need to be prepared to handle it)
say, for example, our script wants to invoke the
$EDITORso user can edit some data (think git commit messages). thatâs a very natural permission request, and itâs also an unmitigated disaster because most editors are configurable enough for running one to be a full sandbox escape. itâs easy for user to absent-mindedly grant
--allow-run=vim(âwell, sure, I want to edit this file!â), but then the script can just set
VIMINIT='!rm -rf /*'and oops, all my files are gone. most programs are configurable, often in some ways you wonât think about (like
LD_PRELOAD), and attacker controls the execution enviornment. âpatch some specific ways to configure programsâ is not the solution, the solution is to sandbox all the code, not just JS code.
when we discussed all this stuff in GitHub issues, a Deno developer wrote:
The main thing it protects you against is malicious code that isn't smart enough.
upon reading this, I was enlightened.
conclusions
deno tries really hard to do what the operating system should be doing. it emulates syscall filtering by filtering standard library calls. unfortunately, this breaks down the second you need to run code that doesnât use Deno standard library. it also uses somewhat flawed approach to permission management that looks good (wow, runtime prompts! omg, I can just
--allow-run=uname!), but introduces security risks. in doing this, it implicitly requires user to understand all the implications of various permissions and their combinations, which can be really hard.
Deno process is not actually sandboxed, it only pretends it is. I didnât mention some more boring issues like âDeno doesnât prevent you from following symlinks that lead outside of the sandboxâ because they all just boil down to this simple one: the process is not sandboxed.
one can argue that this pseudo-sandboxing is better than nothing: after all, not all the malicious code is smart enough. I disagree: Deno permissions give a false sense of security and can lure someone into running untrusted code without proper sandboxing.
itâs possible that you can use Deno sandboxing safely if you:
- never use more dangerous permissions like
--allow-run
, even with=cmd
, - that includes
--allow-env
, even--allow-env=VAR
, for setting environment variables is memory unsafe and can affect libc functions, - preferrably run with
--no-prompt
so your terminal canât be tricked, - are careful about symlinks in directories you
--allow-{read,write}to, - are not concerned with resource exhaustion: thereâre zero permissions required to burn CPU or allocate memory.
in particular, you may be safe if youâre running untrusted code with no permissions at all in a non-interactive context and use some other mechanism to control resource consumption.
I still donât think you should do it. this path is riddled with footguns because youâre executing untrusted code in a fully-privileged process, and the moment you need some OS interactions youâre no longer running code âwith no permissions at allâ. you should not try to figure out which stdlib APIs can lead to a sandbox escape when combined. you should just use a proper sandbox.
Iâm not a fan of Denoâs security model.
so what do we do?
just
bwrapuntrusted shit.
also, please: donât harass Deno developers. we have a technical disagreement, but no technical disagreement was ever solved by harassment. this post is explicitly not an invitation to do it. Iâve already explained my point of view to them, they disagreed, and thatâs okay. you donât need to reiterate it, it wonât help anybody.