A single Node process serves this blog. Astro's SSR output, server/entry.mjs,
comes up inside a container and reads posts out of a SQLite file. So there is a
long-lived process on that machine that can read files and accepts HTTP requests
from outside. One evening, while reading the deploy script, a question occurred
to me: if that process ever tried to read a file it has no business reading,
what would stop it?
The answer was "nothing". Whatever the container's root filesystem offers, Node
can read. Since Node 22.13 there has been an official tool for exactly this: the
permission model. You turn it on with --permission, you say which paths may
be read with --allow-fs-read, and everything else comes back as
ERR_ACCESS_DENIED. The docs mark it Stability 2 — Stable.
So I sat down and tried it. Not to see whether it works — I expected it to work.
My question was different: how many ways are there around the wall? Eight
routes, across four Node versions. On the v22.16.0 on my own machine six of them
worked; on today's stable release, v26.13.0, five still do.
Building the wall
The lab is two directories: one allowed, one not. I also dropped a symbolic link
into the allowed directory — a shortcut pointing at the forbidden file.
$ LAB=$HOME/izin-lab
$ mkdir -p $LAB/izinli $LAB/yasakli
$ echo ISARET_IZINLI_42 > $LAB/izinli/gizli.txt
$ echo 'export const x = 1;' > $LAB/izinli/kod.js
$ echo ISARET_YASAK_99 > $LAB/yasakli/kasa.txt
$ echo 'LAB_ISARET=env_dosyasindan_geldi' > $LAB/yasakli/.env
$ ln -s $LAB/yasakli/kasa.txt $LAB/izinli/kisayol.txt
First I wanted to see the wall actually hold. One grant: the izinli directory.
$ node --permission --allow-fs-read=$LAB/izinli -e '...'
~/izin-lab/izinli/gizli.txt -> ISARET_IZINLI_42
~/izin-lab/yasakli/kasa.txt -> ERR_ACCESS_DENIED
The wall holds. The error object is generous, too — which permission scope,
which resource:
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/Users/mustafaerbay/izin-lab/yasakli/kasa.txt'
Read and write are separate scopes; a read grant buys you no write access:
$ node --permission --allow-fs-read=$LAB/izinli -e "...writeFileSync(izinli/yeni.txt)"
write: ERR_ACCESS_DENIED | FileSystemWrite
So far so good. Now let us count the doors.
Door 1: a symbolic link points outside
Inside the allowed directory sits a shortcut to the forbidden file. Will Node
follow it?
$ node --permission --allow-fs-read=$LAB/izinli \
-e "console.log(require('fs').readFileSync(process.env.LAB+'/izinli/kisayol.txt','utf8'))"
ISARET_YASAK_99
It will. This is not a hole, it is documented behaviour — the "Limitations and
Known Issues" section of Node's permission model documentation says plainly that
symbolic links are followed even to locations outside the set of granted paths.
The docs add a warning: make sure none of the paths you grant contain relative
symbolic links.
In practice that means the directory you pass to --allow-fs-read is now your
responsibility. Any process that can write into it — and node_modules is the
most crowded example of such a directory — can drop a shortcut there and have
the outside read for you. An unpleasant sentence for people like me, who typed
--allow-fs-read=<repo root> and relaxed.
Door 2: everything after the wildcard is ignored
The documentation supports wildcards. Out of plain shell habit, I wanted to say
"read only the JavaScript files":
$ node --permission --allow-fs-read="$LAB/izinli/*.js" \
-e "console.log(require('fs').readFileSync(process.env.LAB+'/izinli/gizli.txt','utf8'))"
ISARET_IZINLI_42
It read the .txt file. Because * is not a shell pattern here: in the docs'
own words, after a wildcard character all subsequent characters are ignored.
So /home/*.js behaves like /home/*. The grant I wrote as izinli/*.js was
applied as izinli/*.
This is the quietest way to over-grant by typo. No error, exit code zero, no log
line. Just a door wider than you intended. Filtering by extension is not the
permission model's job — do it in your application layer if you like, but you
cannot do it on the grant line.
A second detail: when you grant an existing directory, Node appends a *
automatically; for a directory that does not exist, it does not. The measurement:
$ node --permission --allow-fs-read=$LAB/yokdizin -e '...has("fs.read", LAB+"/yokdizin/x")'
yokdizin/x: false
$ node --permission --allow-fs-read=$LAB/yokdizin/'*' -e '...'
with wildcard, yokdizin/x: true
If you are granting an output directory that has not been created yet, write the
wildcard yourself.
Door 3: --env-file reads before the permission model exists
This blog's server has an /etc/mustafaerbay.env with API keys in it. The other
day, when I handed the same .env file to five
parsers,
the measurement showed only 4 of its 24 lines producing the same value
everywhere. Now I asked the same file a different question: does the permission model police a file
read through --env-file?
$ node --permission --env-file=$LAB/yasakli/.env \
-e "console.log(process.env.LAB_ISARET)"
env_dosyasindan_geldi
I granted no --allow-fs-read at all. The file was read anyway. The docs cover
this too: flags such as --env-file and --openssl-config are designed to read
files before environment initialisation, and are therefore not subject to the
permission model's rules. The same goes for V8 flags that can be set at runtime
via v8.setFlagsFromString.
It makes sense — a file is being read while the permission model does not exist
yet. But the consequence stands: having turned the permission model on does not
stop a file named on the command line from being read. Whoever composes the
command line owns that much of the filesystem.
Door 4: node:sqlite walks past the check
This was the one that mattered most to me, because this blog's posts are read out
of a SQLite file. First I put a small database into the forbidden directory —
this step with the permission model off, in an ordinary process:
$ node -e "const {DatabaseSync} = require('node:sqlite');
const db = new DatabaseSync('$LAB/yasakli/deneme.db');
db.exec('CREATE TABLE t(x TEXT)');
db.exec(\"INSERT INTO t VALUES('ISARET_SQLITE_77')\");
db.close();"
Then I opened that same file from a process whose only grant is the izinli
directory:
$ node --permission --allow-fs-read=$LAB/izinli -e "
const {DatabaseSync} = require('node:sqlite');
const db = new DatabaseSync('$LAB/yasakli/deneme.db', {readOnly:true});
console.log('node:sqlite READ:', db.prepare('SELECT x FROM t').get().x);"
node:sqlite READ: ISARET_SQLITE_77
No grant, no obstacle. This is documented as well: the permission model "does not
guarantee that users will not be able to access the file system through other
means, such as through the node:sqlite module." The reason is simple — the
check lives in the node:fs bindings, while SQLite opens the file in its own C++
layer. Where there is no checkpoint, there is no check.
I got the same result on Node 24.22.2 and 26.13.0. So this is not an old
version's gap; it is the model's boundary.
The ordering in that diagram is a claim too: the check sits only on the node:fs
branch. The other three branches either run before the model is set up or never
pass through it.
Door 8: a worker thread given execArgv: []
(I am keeping the table's numbering; 5, 6 and 7 are the next section's subject.)
Node 22's documentation has a single sentence: "The model does not inherit to a
worker thread." I read it and concluded workers run free. Measuring showed the
opposite. Note that both runs below do pass --allow-worker — without it,
constructing the Worker would already be refused in the WorkerThreads scope
and the thing being measured would not be a file read at all:
$ node --permission --allow-fs-read=$LAB/izinli --allow-worker \
-e "new (require('worker_threads').Worker)('$LAB/izinli/oku.cjs')"
(node:75778) SecurityWarning: The flag --allow-worker must be used with extreme caution. ...
(node:75778) SecurityWarning: The flag --allow-worker must be used with extreme caution. ...
CLOSED ERR_ACCESS_DENIED
$ node --permission --allow-fs-read=$LAB/izinli --allow-worker \
-e "new (require('worker_threads').Worker)('$LAB/izinli/oku.cjs',{execArgv:[]})"
(node:75780) SecurityWarning: The flag --allow-worker must be used with extreme caution. ...
OPEN ISARET_YASAK_99
The default Worker was blocked. The one that was not blocked is the one I
opened with execArgv: []. The reason: a default worker inherits the parent's
CLI flags, --permission and the --allow-* set included. When you pass
execArgv explicitly — the empty array counts — that list replaces the inherited
flags, and the grants disappear with them.
The proof is sitting in the output already: the first run printed the
SecurityWarning twice, the second once. In the first case the worker that
inherited the flags re-emits the warning on its own account; the worker given
execArgv: [] never received those flags, so it stays quiet. You do not even
need to call process.permission.has() to tell whether inheritance happened.
Current Node documentation now spells this distinction out and adds that the
difference is intended, not a bypass. That single sentence in the Node 22 docs,
on its own, points the reader the wrong way; without measuring I would have
stayed on the wrong side of it. When a doc's short sentence and the code's
behaviour disagree, the code wins.
The door that closed: spawn versus fork
This was the most interesting result. On my own machine, Node v22.16.0, the
parent process could not read the forbidden file but its child could:
== v22.16.0 ==
5 spawn OPEN
6 fork CLOSED ERR_ACCESS_DENIED
Same parent, same file, two different APIs, opposite outcomes. fork() copies
the parent's execArgv to the child — so --permission travels with it and the
child is born inside the jail. spawn() was not copying it: the child started as
if the permission model had never been switched on. Node prints an honest warning
while this happens:
SecurityWarning: The flag --allow-child-process must be used with extreme
caution. It could invalidate the permission model.
I ran the same script on three more versions — two in containers (Linux), the
last one on my own machine again, with the current LTS downloaded from
nodejs.org:
| Route | v22.16.0 | v22.23.4 | v24.22.2 | v26.13.0 |
|---|---|---|---|---|
| 0 direct read | closed | closed | closed | closed |
| 1 symbolic link | open | open | open | open |
| 2 wildcard truncation | open | open | open | open |
3 --env-file
|
open | open | open | open |
4 node:sqlite
|
open | open | open | open |
5 spawn
|
open | closed | closed | closed |
6 fork
|
closed | closed | closed | closed |
7 worker
|
closed | closed | closed | closed |
8 worker execArgv:[]
|
open | open | open | open |
One thing to know while reading that table: 22.16 and 26.13 ran on macOS, 22.23
and 24.22 in Linux containers. In an article about symbolic links and path
grants that is not a trivial detail — but the only difference between the
columns is the spawn row, and the cause of that difference is not the
operating system, it is the patch below. v26.13.0 is the newest stable release
nodejs.org listed on the day I wrote this.
The closing point is on record: the patch titled "permission: propagate
permission model flags on spawn" landed in Node 22.18.0. Its own description
states exactly what the lab showed — previously only child_process.fork
propagated the exec arguments to the child process. Now spawn and
spawnSync do too, not on the command line but through the NODE_OPTIONS
environment variable. The
22.16 on my machine was two patch releases short of it.
The lesson here is about reading versions. "We're on Node 22" maps to two
different rows of that table. Security behaviour changed inside a single major
line.
One more note: none of these experiments ran on Node 20 at all.
== v20.20.2 ==
0 direct read node: bad option: --permission
On Node 20 the flag is called --experimental-permission. The new name became
stable in v22.13; the old name is still accepted as an alias across the 22 line.
But the trap is symmetric, and that went through the lab too:
$ docker run --rm node:22-alpine node --experimental-permission ...
accepted
$ docker run --rm node:24-alpine node --experimental-permission ...
node: bad option: --experimental-permission
The old name was removed in Node 24. So it is a minefield in both directions:
write --permission and the process never starts on a Node 20 image; write
--experimental-permission and it never starts on a Node 24 one. Both die with
"bad option", and both die before any of your application's code runs — which at
least makes it a loud failure rather than a quiet one.
The comma trap: I wrote two directories, I got zero
I did not go looking for this one; I fell into it. Wanting to allow two
directories, I reached for a comma out of habit from other command-line tools:
$ node --permission --allow-fs-read="$LAB/izinli,$LAB/yasakli" \
-e "console.log('izinli:',process.permission.has('fs.read','$LAB/izinli/gizli.txt'))"
izinli: false
(node:75172) Warning: The --allow-fs-read CLI flag has changed. Passing a
comma-separated list of paths is no longer valid. Documentation can be found
at https://nodejs.org/api/permissions.html#file-system-permissions
exit code: 0
Neither. The comma-separated list counts as a single path name, and since nothing
exists under that name, no grant is created at all.
Here I have to own a measurement mistake of my own. The first time I ran this
line I was filtering the output through grep -vi warning — a filter I had added
to drop ExperimentalWarning noise from the container runs. That filter
swallowed the warning above, and for a while I walked around believing Node says
nothing at all. Node does say something, and says it well: that the flag's
behaviour changed, with a link to the documentation. The comma form was
invalidated back in v20.7.0.
The trap is not silence, it is severity: this is a Warning, not an
Error. The process starts, the exit code is zero, the application runs — you
simply have no permissions anywhere. For someone filtering warnings out of CI
output, which is exactly what I was doing, it is easy to miss. The correct form
is to repeat the flag:
$ node --permission --allow-fs-read="$LAB/izinli" --allow-fs-read="$LAB/yasakli" -e '...'
izinli: true | yasakli: true
The difference between those two lines could be a systemd unit you believe you
have hardened granting nothing at all, or your application dying on its first
read. After writing your grants, verify them once with
process.permission.has(); it is a one-liner:
$ node --permission --allow-fs-read=/opt/app \
-e "console.log(process.permission.has('fs.read','/opt/app/dist/x'))"
true
I put my own validator in jail
The lab is all very well, but the real question was what happens in production. This
repository has a 126-line script, scripts/validate-content-sources.mjs, that
checks every article's source list. I tried running it under the permission model
and raised the grants one at a time.
$ node --permission scripts/validate-content-sources.mjs --staged
permission: 'FileSystemRead',
resource: '.../scripts/validate-content-sources.mjs'
$ node --permission --allow-fs-read="$PWD" scripts/validate-content-sources.mjs --staged
permission: 'ChildProcess',
resource: ''
$ node --permission --allow-fs-read="$PWD" --allow-child-process scripts/validate-content-sources.mjs --staged
Kaynak dogrulamasi: staged icerik yok. # "source check: nothing staged"
On the first step it could not read the script itself. On the second it stopped
in the ChildProcess scope, because the script calls git diff --cached through
execFileSync.
That first step was leading me to a false generalisation: "under the permission
model you have to grant even your application's own entry file." Before writing
it down I ran the same thing on the other versions, and the generalisation
collapsed:
$ node --permission ~/izin-lab/giris.cjs # v22.16.0
Error: Access to this API has been restricted...
$ docker run ... node:22-alpine node --permission /s/giris.cjs
entry point ran: v22.23.4
$ docker run ... node:24-alpine node --permission /s/giris.cjs
entry point ran: v24.22.2
$ ./node-v26.13.0/bin/node --permission ~/izin-lab/giris.cjs
entry point ran: v26.13.0
No grants at all, and three of them ran. The reason sits in the docs, in the
version history row for --allow-fs-read: v24.2.0 and v22.17.0 —
"Entrypoints of your application are allowed to be read implicitly."
So the second piece of evidence for this article's backbone — the patch version
is a behaviour version — is inside the same minor line as the first, one patch
earlier. 22.17 opened the entry point, 22.18 brought flag propagation on
spawn. The 22.16 on my machine predates both, which is why the three-step
escalation above reproduces exactly only on that version. On 22.17 and later the
first step does not fail and you start from the second.
That implicit grant ends in a subtle place. I called the same file, with the same
version, through a path whose parent is a symbolic link — on macOS, /tmp is a
link to private/tmp:
$ ./node-v26.13.0/bin/node --permission /tmp/.../giris.cjs
Error: Access to this API has been restricted...
at Object.realpathSync (node:fs:3479:37)
$ ./node-v26.13.0/bin/node --permission /private/tmp/.../giris.cjs
entry point ran: v26.13.0
Same file, same flag, two spellings. The implicit grant given to the entry point
does not cover resolving that path to its real one; the realpathSync during
resolution wants a grant. Anyone running scripts out of /tmp on macOS will meet
this, and it belongs to the "why doesn't this happen on my machine" family.
The minimal pair turned out to be: read access to the repository root, plus
--allow-child-process. Three runs, same result. But here is the irony: to put a
126-line validator in jail, I have to hand it the grant that — on the Node version
on my machine — removes the jail entirely for its children. On a pre-22.18
Node, the protective value of this setup is limited to the parent's own fs
calls.
The tool chain, not my code, sets the grant list
The scripts that render cover images run on TypeScript through tsx. I tried to
jail that too — without even giving it work to do, just asking for its version.
$ node --permission --allow-fs-read="$R" --allow-fs-read="$NM" node_modules/.bin/tsx --version
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemWrite',
resource: '/var/folders/nn/fmy2b8m575143sfsvs3wrfz40000gn/T/tsx-501'
tsx writes a cache into the operating system's temporary directory. I granted
the write, and it stopped in a different scope:
$ node --permission ... --allow-fs-read="$T" --allow-fs-write="$T" node_modules/.bin/tsx --version
tsx v4.21.0
code: 'ERR_ACCESS_DENIED',
permission: 'ChildProcess',
resource: ''
It printed the version, then asked for a child process and stopped. The full
working list came to five grants: repo read, node_modules read, temp directory
read, temp directory write, child process. All of that to say tsx --version.
That number has nothing to do with my code. The script I wrote reads two files
and writes a PNG; the length of the grant list is set by the TypeScript runner's
caching strategy and its decision to invoke the compiler in a separate process.
If you are going to attach the permission model to your application, what you are
constraining is not the code you wrote but the entire stack underneath it.
The addon door: courtesy or model?
On the blog's SSR side I hit a different wall. Posts are read with
better-sqlite3, and that is a native addon:
$ node --permission --allow-fs-read="$R" --allow-fs-read="$NM" ... -e "...new Database(...)"
require passed
addon blocked: ERR_DLOPEN_DISABLED
The docs say this as well: under the permission model a process cannot load
native addons by default, and without --allow-addons you get
ERR_DLOPEN_DISABLED. The require('better-sqlite3') line on its own goes
through; the failure happens at the first Database object, the moment the
.node file is actually loaded.
When I did pass --allow-addons, the attempt to open the forbidden database
stopped again — but not because of the permission model:
code: ERR_ACCESS_DENIED | resource: /Users/mustafaerbay/izin-lab/yasakli
at Object.existsSync (node:fs:292:18)
at new Database (.../node_modules/better-sqlite3/lib/database.js:64:57)
What stopped it was better-sqlite3's own existsSync call in its JavaScript
layer. The library's courtesy saved me, not the model itself. Doing the same work
with the core node:sqlite module — Door 4 — nobody stopped it at all. The
third-party library got caught because it happened to go through a checked path;
the core module went through because it does not. Read that sentence backwards
and it says a dependency upgrade can quietly change your level of protection.
I did not have to hunt for grants by hand
What I did in the previous two sections — run, take the error, add a grant, run
again — works, but it is primitive. --permission-audit, which arrived in Node
25.8, is precisely the automated version of it: the checks run, violations are
published over node:diagnostics_channel, but access is not denied.
$ cat denetim.cjs
const dc = require('node:diagnostics_channel');
for (const scope of ['fs', 'child', 'worker'])
dc.subscribe('node:permission-model:' + scope,
(m) => console.log('violation:', m.permission, '->', m.resource));
require('fs').readFileSync(process.env.SIR, 'utf8');
require('child_process').spawnSync(process.execPath, ['-e', '0']);
console.log('script ran to the end');
$ ./node-v26.13.0/bin/node --permission-audit denetim.cjs
violation: FileSystemRead -> /Users/mustafaerbay/izin-lab/yasakli/kasa.txt
violation: ChildProcess -> .../bin/node
script ran to the end
The whole shopping list in one run: which scope, which resource. In enforcing
mode the same script stops at the first ChildProcess violation and you never
see the rest — which is also why my escalation took three rounds.
A warning: leaving this on in production is not protection, only measurement.
Audit mode blocks nothing. In a real deployment the order should be — first
--permission-audit to produce the list, then write the grants, then enforce
with --permission.
So what is this good for?
Looking at the table so far it is easy to say "pointless", but I think that would
be wrong. Node's own documentation says what it is not in its very first
paragraph: this feature does not protect against malicious code; Node trusts any
code it is asked to run. Malicious code can bypass the permission model and
execute arbitrary code without its restrictions.
So it is not a sandbox, it is a guard rail. When those two get confused,
people lean on the wrong thing. After measuring, I wrote down four questions for
myself:
-
What kind of mistake am I protecting against? It helps against a
readFileSyncthat lost its way, a misconfigured backup script, a library that decides it would like to read/etctoo. It does not help against an attacker who can run code inside the process. -
Which grant am I handing over?
--allow-child-process,--allow-addonsand--allow-workerlower the wall the moment they are given. Node says so itself: passing each of the four in turn (--allow-wasiincluded) printed the sameSecurityWarningline — "must be used with extreme caution. It could invalidate the permission model." - Who can write into the directory I allowed? The symbolic link door closes on exactly this question. Anyone with write access can bore through your read wall.
- On which Node version? The table above shows why this is a separate question. The patch version is a behaviour version.
If you want an operating-system-level jail, this is not the place. I previously
wrote about Landlock, where an application confines
itself:
there the check lives in the kernel, and neither node:sqlite nor a .node file
can walk around the rule. The permission model's place is not to replace a kernel
restriction, but to sit as a thin layer above it.
What I settled on
I am not putting the blog's SSR process under the permission model today. The
reason is in the table: posts are read by better-sqlite3, so I would have to
pass --allow-addons; once that grant is given part of the protection goes with
it, and what I get in return is preventing code I wrote myself from accidentally
reading /etc. That trade is already solved more cleanly at the container level
— read-only mounts, a separate user, a narrow filesystem.
But I am considering it for the one-shot scripts under scripts/. They are
short-lived, have few dependencies, mostly use no native addons, and what they
will read is known up front. A guard rail is exactly for places like that. And
the first step is no longer hunting for grants by hand: one run of
--permission-audit, then turn the list into flags.
One last note in fairness: eight routes is not an exhaustive list. The docs' own
"limitations" section names others I did not try — operations on already-open
file descriptors, FFI, OpenSSL loaders, the trace files written by
--trace-event-file-pattern. The lab covered only the ones that could plausibly
show up in my own setup.
I tried eight doors, five are still open, and all five are written down in the
documentation. The difference between reading an instrument's limits and seeing
them measured turned out to be this: I had skimmed past the sentence "symbolic
links will be followed" three times, but I had not noticed what it did to my
--allow-fs-read=<repo root> line until ISARET_YASAK_99 appeared on screen.
Top comments (1)
bro 🔥 the wildcard door got mee, izinli/.js silenty becoming izinli/ with exit code 0 and no log line is the worst kind of bug, u think yourr restricted but you are not. i would 100% type that and move on lol
the execArgv: [] one is clever too, using the SecurityWarning printed twice vs once as proof of inheritance, never thought about that trick......
the better-sqlite3 part scared me... so its existsSync in the JS layer saved you and not the model itself, means a dependency update can quitely change your protection?? thats kinda crazyyyyy
with yarn/pnpm workspaces the linked packages in node_modules point outside of it, so by door 1 logic they would just be followed even if u never granted the packages folder right? did you test that or nott? if yes then its a accidental hole AND accidental fix at the same time haha :)
also the grep -vi warning self own made me laugh, i do exact same thing in CI