That JS supports await isn't nearly as much a problem as the fact that it defensively defers execution any time it encounters one.
This hurts most badly for iterators.
Files are usually read from disks in chunks of 64K bytes. If you present the data as an async byte iterator you'll get 65,534 defensive deferrals, and one actual wait.
If you have ten layers of transformation it will be 655,350 defensive deferrals and 10 real waits.
These defensive deferrals aren't so expensive in terms of CPU time, but they ARE very expensive in terms of wall clock time. All other work must be halted so the event loop code can run. Eventually synthetic deferrals dominate the wall clock cost of running the program. This isn't hypothetical either. Here's someone trying to build steam parsing on Node and finding that it's too slow to be useful: node#31979
The solution is stream iterators. They are sync when they can be and async when they must be: docs.bablr.org
My Pool Has 4 Threads, DNS Gets 2
In Node, the word "asynchronous" does not describe a single thing. Some calls
really are handed to the kernel and come back when the operating system is
ready; others are placed on one of four background threads and wait their turn.
The second kind is libuv's work pool, its size is four, and that number appears
in Node's documentation in a single aside: "its current default value".
What led me to that number was my cover generator. In batch mode
generate-hero.ts drains its queue with four workers — a hard-coded 4 inside
the script — and each worker is one sharp call. In a separate run, in a
separate process, my social posting script names nine external hosts in its
source. Both are "asynchronous", both sit on their own four threads, and I had
no idea how much of those four either one was holding. I wanted to measure this
properly, once. The result was sharper than I expected: dns.lookup can never
take more than two of those four threads, and neither libuv's documentation
nor Node's mentions that limit.
Who lands in the pool
Node's command-line documentation spells out the list: all fs calls except the
file watchers and the explicitly synchronous ones, asynchronous crypto calls
such as crypto.pbkdf2, scrypt, randomBytes and generateKeyPair, all
zlib calls except the explicitly synchronous ones, and dns.lookup. The
warning that follows is fair: because the pool has a fixed size, if one of these
takes a long time, other "seemingly unrelated" ones get slower.
The sneakiest member of that list is dns.lookup, because you are usually not
the one deciding to call it. In Node's lib/net.js, the connect path says it in
one line: const lookup = options.lookup || dns.lookup;. So net.connect,
http, https and fetch — undici, which sits underneath fetch, uses
net.connect for plain HTTP and tls.connect for TLS, and both pass through
that same line — resolve names through dns.lookup unless you supply your own
resolver. The first step of every outbound HTTP request queues on the same four
threads as your file reads.
The dns.resolve* family is not on that list. Node's documentation draws the
line clearly: those functions do not use getaddrinfo(3), they always query
over the network, and they do not use libuv's threadpool. Keep that distinction
in mind; we will come back to it with a measurement at the end.
The lab: my own resolver, a fixed 300 ms
Measuring against a real DNS server is pointless; if the latency moves, you
cannot read the table. So I wrote a 49-line UDP server: it holds every incoming
query for exactly 300 ms, then answers 127.0.0.2, sets TTL zero (no caching),
and most importantly it counts how many distinct names are in flight at the
same time. Instead of guessing concurrency from the client side, I let the
resolver count it.
The lab ran in a node:26-alpine container: Alpine 3.24, Node v26.13.0, libuv
1.52.1, aarch64, 14 cores. /etc/resolv.conf has one line: `nameserver
127.0.0.1. The client calls dns.lookup` for 24 distinct names at once, with
family: 4, so each name sends a single A query and the resolver's A/AAAA
behaviour stays out of the table. The numbers below are medians of three runs.
Alpine uses musl; since the limit measured here lives in libuv, the resolver
library does not change the result, but the nsswitch.conf argument later in
this article applies to glibc setups.
| UV_THREADPOOL_SIZE | Concurrent queries counted by the resolver | 24 names, total |
|---|---|---|
| unset (4) | 2 | 3,695 ms |
| 1 | 1 | 7,414 ms |
| 2 | 1 | 7,427 ms |
| 3 | 2 | 3,703 ms |
| 4 | 2 | 3,712 ms |
| 6 | 3 | 2,480 ms |
| 8 | 4 | 1,859 ms |
| 16 | 8 | 934 ms |
The second column is exactly half the pool size, rounded up.
What amuses me most in this table is the relationship between the second and
third rows. Going from one thread to two moves the total for 24 names from 7,414
ms to 7,427 ms — that is, nowhere. I doubled the threads and what I gained on
the DNS side is smaller than the measurement noise. But going from two to three
cuts the time in half. Adding one thread buys nothing once and a factor of two
the next time.
The reason sits in libuv's source, on line 46 of src/threadpool.c:
static unsigned int slow_work_thread_threshold(void) {
return (nthreads + 1) / 2;
}
libuv sorts work into three kinds: UV__WORK_CPU, UV__WORK_FAST_IO and
UV__WORK_SLOW_IO. src/unix/getaddrinfo.c submits its request as
UV__WORK_SLOW_IO; src/unix/fs.c submits every file operation as
UV__WORK_FAST_IO, and user code calling uv_queue_work submits as
UV__WORK_CPU. Slow-I/O work piles up in a separate queue and only a single
"there is slow work" marker goes into the main queue. When a thread picks up
that marker and starts running slow work, a counter goes up; if the counter has
reached the threshold, the thread pushes the marker back to the tail of the
queue and looks for other work.
!Diagram
So this is not an accident but a deliberate guard: it exists so a slow name
lookup cannot swallow the whole pool and block file reads. I have no objection
to the logic. My objection is that the limit is written down nowhere. libuv's
own documentation covers the default, the absolute maximum, and that the pool is
global and shared across all loops; for its scope it says "all file system
operations, as well as getaddrinfo and getnameinfo requests". Not one sentence
about the half-pool threshold. Node's UV_THREADPOOL_SIZE section does not have
it either: there the advice is simply to set a value greater than 4.
Follow that advice and DNS can use at most half of whatever you set. Write 8 and
the ceiling on your name-resolution concurrency is 4. It is a ceiling, not an
allocation: when there is no DNS work those slots do not sit idle, they take
other work.
Who starves whom
The direction of the threshold matters, because it is asymmetric. Two scenarios,
both with the pool at its default four threads:
A) With 32 pending dns.lookup calls (each answered by my 300 ms resolver)
I squeezed in one fs.readFile and one pbkdf2. The fs.readFile waited 0 ms,
the pbkdf2 2 ms. Thirty-two names are queued and the file read goes straight
through. The guard works.
B) With 32 pending pbkdf2 calls — a computation that takes 208 ms on its
own — I squeezed in one dns.lookup and one fs.readFile. The fs.readFile
waited 1,719 ms, the dns.lookup 2,018 ms.
The arithmetic of that second line lands exactly: 32 × 208 ms on four threads is
a 1,664 ms queue, and the measurement is 1,719 ms. DNS's 2,018 ms comes from the
same place — 1,719 ms of queue plus my resolver's fixed 300 ms, totalling 2,019
ms. The resolver answers in 300 ms while dns.lookup takes two seconds, and 85
percent of that has nothing to do with resolving a name.
The lesson here runs the other way: some investigations that start with "our DNS
is slow" are looking in the wrong place. What fills the pool is not name
resolution but the crypto, compression or image work in the same process. The
slow-I/O threshold stops DNS from choking everyone else; nothing stops everyone
else from choking DNS.
Where the threshold sits on my own pipeline
The same question goes to my cover generator, since that is where it started.
came from. generate-hero.ts makes a single sharp call: it rasterises the SVG
at 200 dpi, fits it to 1200×630 and writes a PNG with compressionLevel: 9. On
my Mac (Node v22.16.0, libuv 1.49.2, sharp 0.35.5, libvips 8.18.7, 14 cores)
that call takes 75 ms on its own. On an idle pool an fs.readFile takes 0.2 ms.
I started the same call N times concurrently and squeezed in a read of a real
article file:
| Concurrent covers | How long fs.readFile waited |
|---|---|
| 1 | 0 ms |
| 2 | 0 ms |
| 3 | 0 ms |
| 4 | 9 ms (three runs: 1 – 31 ms) |
| 8 | 62 ms (three runs: 56 – 69 ms) |
The threshold is exactly at four. Up to three there is still a free thread in
the pool, so the read never waits; the moment a fourth cover enters, it starts
waiting. I will not lean on the 9 ms in the fourth row — across three runs it
wandered between 1 and 31 ms, because the instant a thread frees up is a matter
of chance. At eight the number is steady: about 60 ms.
Something caught my eye here. My script does not only write two PNGs per article
and exit; in --all mode it drains the queue with four workers, and that four
is a constant I put there myself: Array.from({ length: 4 }, ...). The comment
next to it says it "gains parallel I/O". So when I rebuild the covers for all
2,772 posts I fill the entire pool, and the fourth row of the table is exactly
that point. When I wrote that 4 I did not know the pool was also four; the two
numbers matching is a coincidence, what follows from it is not — during an
--all run every fs read and every dns.lookup in the same process queues
behind it.
The absolute values are small, I know. What interests me is not the absolute
value but that the threshold is visible at all. And one more thing: there is not
a single line in my repository that mentions UV_THREADPOOL_SIZE. Not in the
scripts, not in the systemd units, not in the workflows. I have been running
on four threads and until today I had not chosen that deliberately.
When exactly is the pool born
Node's documentation says that setting process.env.UV_THREADPOOL_SIZE "is not
guaranteed to work" and gives a reason: the pool would have been created "as
part of the runtime initialisation much before user code is run". The warning
itself is right; the reason does not hold on the Node in this lab.
libuv's pool is lazy. The first line of uv__work_submit is `uv_once(&once,
init_once)` — the pool is born when the first piece of work is submitted. We can
count it from outside, because since libuv 1.50.0 the threads are named
libuv-worker:
# process is up but nothing has hit the pool yet
$ ls /proc/<pid>/task/*/comm | xargs cat | grep -c libuv-worker
0
After a single dns.lookup, that same count returns whatever
UV_THREADPOOL_SIZE says — 4 if you wrote 4, 128 if you wrote 128, for one
request. That is precisely libuv's sentence about preallocating the maximum
number of threads allowed. Five rows, same lab, on where the value is set:
| Where it was set | Concurrency counted by the resolver | 24 names |
|---|---|---|
| nowhere (default 4) | 2 | 3,695 ms |
| first line of the JS: process.env... = '16' | 8 | 930 ms |
| the same line after one fs.readFile | 2 | 3,720 ms |
| UV_THREADPOOL_SIZE=16 in the shell | 8 | 923 ms |
| node --env-file=uv.env | 8 | 934 ms |
The third row vindicates the documentation's warning, but for a different
reason: the setting is not missed because the runtime built the pool early; it
is missed because someone submitted work to the pool before you did. A library
that reads a file with top-level await, or a dependency that calls
randomBytes, quietly makes your setting irrelevant. So the advice stands: do
not set this value from inside your code. The two honest paths in the table
above are the shell environment and --env-file; both land before the first
job.
The bill for growing it
The ceiling is 1024 (libuv 1.30.0 raised it from 128) and each thread wants an 8
MiB stack (since 1.45.0; before that it was the platform default). Readings from
the container's /proc/<pid>/status after a single dns.lookup:
| UV_THREADPOOL_SIZE | libuv-worker | VmSize | VmRSS | Total threads |
|---|---|---|---|---|
| unset (4) | 4 | 848 MiB | 61 MiB | 11 |
| 8 | 8 | 880 MiB | 61 MiB | 15 |
| 128 | 128 | 1,841 MiB | 61 MiB | 135 |
| 1024 | 1024 | 9,021 MiB | 75 MiB | 1,031 |
Going from four to eight adds 32 MiB to VmSize: four threads × 8 MiB, exact.
At 1024 the virtual size reaches 9 GiB while resident memory stays at 75 MiB —
the stacks are paged lazily, just as libuv's documentation says. So the thing to
worry about is not RSS; it is virtual address space in an environment capped by
ulimit -v, and a scheduler that now has 1,031 threads to deal with.
My decision: for a process like my blog pipeline, which writes a couple of
images per article and talks to five platforms, 16 is more than enough. Values
in the hundreds only make sense in a process that genuinely makes hundreds of
concurrent slow calls, and even there the real question is not the pool size but
why those calls are concurrent.
How to leave the limit behind entirely
The cleanest exit is not to grow the pool but not to use it. dns.resolve4 runs
on c-ares; the query happens on the event loop, not in the pool. I asked the
same 24 names of the same 300 ms resolver, with the default four-thread
pool:
| Method | Concurrent | 24 names |
|---|---|---|
| dns.lookup | 2 | 3,695 ms |
| dns.resolve4 | 24 | 312 ms |
All twenty-four in flight, and the total is the duration of one query. More than
an eleven-fold difference, and I never touched the pool.
It is not free, of course. Node's documentation states the difference honestly:
dns.resolve* does not read /etc/hosts, and it does not use nsswitch.conf
and resolv.conf the way dns.lookup does. So there is no guarantee it gives
the same answer as ping. In a container, service names written into
/etc/hosts, mDNS, search domains — all of that disappears on this path. The
middle ground the documentation points to is more sensible: resolve the name
with dns.resolve() and use the address, or replace the lookup option that
APIs such as socket.connect() and dgram.createSocket() accept with your own
implementation — which is also exactly where a cache belongs.
Questions to ask about your own setup
- How many distinct hosts does your process talk to? Each one means a default
dns.lookup, and they all share at most two threads.
- Does the same process do image processing, zlib, pbkdf2 or heavy file
reads? If so, what fills the pool is probably not DNS but those.
- Is UV_THREADPOOL_SIZE set from inside the code? If you cannot prove no work
hit the pool before that line, the setting may be ignored.
- Do you know how many threads your pool opened with? On Linux, counting
libuv-worker in /proc/<pid>/task/*/comm takes two seconds (libuv 1.50.0 and
above); on macOS you need sample or vmmap.
- If you have a slow-resolver problem, should the answer be dns.resolve? Yes
if you have no dependency on /etc/hosts and nsswitch; otherwise your own
lookup.
- If your traffic goes through global fetch, how do you hand it a lookup?
fetch takes no such option; the path runs through undici's dispatcher —
setGlobalDispatcher(new Agent({ connect: { lookup } })).
- You opened eight worker_threads and assume you have eight pools? The thread
count and the queues in threadpool.c are static: there is one pool per
process. Eight workers share the same four threads and the same two DNS slots.
- What happens when your nameserver dies? A getaddrinfo that has already
started cannot be cancelled — uv__work_cancel can only pull work that has not
begun off the queue — so two threads stay pinned for as long as `timeout ×
attempts in resolv.conf` says.
Limits without counters
Not one measurement in this article produces an alert. There is no counter that
goes up when the pool fills. The tooling is not entirely absent: perf_hooks
has a 'dns' entry type that tells you how long dns.lookup took, and the gap
between the node.threadpoolwork.async and .sync traces gives you the time
spent queueing. But the first one does not tell you why it took that long, and
the second is never emitted by the pool's two busiest customers — dns.lookup
and fs — because neither goes through the code that emits it. The limit is
there; in practice the only evidence is in your p99.
I ran into the same shape in the [CPU quota
article](mustafaerbay.com.tr): a process using
seventeen percent of its quota, throttled half a million times in ten days,
invisible to every dashboard that looks at averages. In the [keepalive
article](mustafaerbay.com.tr) the
setting was right but almost no application read it. Here is a third variant:
the setting is documented, it is recommended, and half of the recommendation is
lost on the way — and the place it is lost is written on line 46 of the source.
Two threads out of four. If you did not choose that deliberately, libuv chose it
for you.
Official Sources
- Node.js — UV_THREADPOOL_SIZE (CLI documentation)
- Node.js — DNS: Implementation considerations
- libuv — src/threadpool.c (v1.x branch)
- libuv — Thread pool work scheduling documentation
- libuv — src/unix/getaddrinfo.c (v1.x branch)
- Node.js — lib/net.js (main branch)
5 reactions
I Locked Node Into One Directory — Five Doors Stayed Open
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](mustafaerbay.com.tr),
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.
!Diagram
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
readFileSync that lost its way, a misconfigured backup script, a library that
decides it would like to read /etc too. It does not help against an attacker
who can run code inside the process.
- Which grant am I handing over? --allow-child-process, --allow-addons
and --allow-worker lower the wall the moment they are given. Node says so
itself: passing each of the four in turn (--allow-wasi included) printed the
same SecurityWarning line — "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](mustafaerbay.com.tr):
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.
Official Sources
- Node.js — Permissions (permission model documentation)
- Node.js — Command-line API (--permission and the --allow-* flags)
- nodejs/node — "permission: propagate permission model flags on spawn" patch
- Node.js v22.x — Permissions (the 22 line's own documentation)
6 reactions · 1 comment
🔏 Time to work on Node.js macOS notarization and finally fix the root cause:
node#67140
4 likes
Your GitHub Actions Jobs May Now Run on AWS | Dev News
Five developer stories and some updates in about four minutes: what happened, why it matters, and what to do.
1. GitHub Actions: eligible Linux jobs on standard 2- and 4-core runners may now run on AWS as well as Azure (automatic on Free, Pro and Team; Enterprise Cloud opts in). They can use new outbound IPs, so allowlists built on Azure ranges, including Entra Conditional Access, can block them: sync with the actions list from api.github.com/meta. Included Actions usage is now a dollar amount ($12 Free, $18 Pro/Team, $300 Enterprise Cloud a month), and Copilot cloud sandboxes draw from it, with billing from November. macos-14 jobs fail during brownouts Oct 29 and Oct 30, 14:00 to 24:00 UTC; the image retires Nov 2.
2. CISA added Linux kernel CVE-2026-46300 ("Fragnesia", per Red Hat) to its Known Exploited Vulnerabilities list, due Oct 31, saying it can be chained with PHP2Shell to plant web shells and run commands as root. Patch PHP (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35) and the host kernel (fixed upstream in 5.10.257, 5.15.208, 6.1.174, 6.6.141, 6.12.91, 6.18.33, 7.0.10, or your distro's update). PHP2Shell's federal deadline is Oct 29.
3. Jenkins fixed 15 core vulnerabilities (13 High), including a setup-wizard race that lets an unauthenticated attacker create an admin account on a new instance. Upgrade to 2.588 or LTS 2.580.2.
4. Apache Kafka disclosed 12 vulnerabilities. A Kafka 4.1–4.3.0 broker using SASL/OAUTHBEARER with no JWKS endpoint URL (the default) skips signature checks and accepts unsigned tokens (CVE-2026-110985). Kafka Streams apps and consumers using ListSerde can be crashed by a crafted record. Set sasl.oauthbearer.jwks.endpoint.url, then upgrade brokers and client libraries to 4.2.2, 4.3.2 or 4.4.0.
5. PyTorch 2.15 drops Python 3.10, needs NVIDIA driver 580+ and a Turing (sm_75) or newer GPU for the default CUDA wheels, needs macOS 15 for MPS, and writes distributed checkpoints 2.14 can't load. Pin torch.
Updates: Node.js 26 'Lithium' became LTS on Oct 28 (26.13.0); the first Node 27 alpha slipped and is expected shortly. Tailscale 1.104.4 fixes its SSH and Serve flaws. On Oct 30, Amazon Pinpoint's console and engagement features end, and Cloud Run functions decommission Node.js 20.
⏱ Chapters
0:00 Intro
0:13 Today's rundown
0:43 GitHub Actions jobs may now run on AWS
1:29 PHP2Shell gets root: a kernel bug joins KEV
1:59 Jenkins fixes 15 core flaws
2:23 Kafka brokers can accept unsigned tokens
2:57 PyTorch 2.15 drops Python 3.10
3:23 Updates
3:48 Sign-off
📰 Sources
GitHub Actions multi-cloud runners: github.blog
Copilot cloud and the dollar-based Actions pool: github.blog
GitHub Docs, included usage: docs.github.com
macos-14 retirement: github.blog
CISA KEV catalog (feed): cisa.gov
CVE-2026-46300 record: cveawg.mitre.org
Red Hat on CVE-2026-46300: access.redhat.com
Palo Alto advisory: security.paloaltonetworks.com
Jenkins Security Advisory 2026-10-28: jenkins.io
Apache Kafka CVE list: kafka.apache.org
Kafka advisories on oss-security (Oct 28): openwall.com
PyTorch 2.15 release notes: github.com/pytorch/pytorch/releases/tag/v2.15.0
Node.js 26.13.0: nodejs.org
Node.js 27 alpha release PR: node#67062
Tailscale security bulletins: tailscale.com
Amazon Pinpoint end of support: docs.aws.amazon.com
Cloud Run functions runtime support: cloud.google.com
Just Known Daily: developer news, every day. From unknown to known. A channel by Just Unknown.
#technews #github #devops #cybersecurity #pytorch #developers
56 views
This change makes inflateSync 57-72% and deflateSync 12% faster node#66864
14 likes · 497 views
This change makes inflate 350% faster on small buffer, and 40-47% faster on large buffer. node#66863
12 likes · 604 views
yeah, I'm missing Deno.serve the most :-(
Maybe one day: node#63096
1 like
자바스크립트는 브라우저 전용이라는 상식, 완전히 틀렸습니다. | nodejs/node
자바스크립트는 브라우저 전용이라는 상식, 완전히 틀렸습니다. | nodejs/node
GitHub의 nodejs/node는 바로 Node.js! 오픈소스 크로스플랫폼 자바스크립트 런타임으로, 어디서든 자바스크립트를 실행하게 해줍니다. 핵심은 두 가지! 첫째, 매년 4월 새 메이저 버전을 내는 규칙적인 릴리스 체계. 둘째, 무려 30개월간 지원되는 LTS로 보안과 안정성까지 책임지죠. 한줄평: 자바스크립트 생태계의 심장, 아직 안 써봤다면 지금 만나보세요!
Repo: nodejs/node #굿리포 #GitHub #오픈소스
2 likes · 550 views
Huge fan of @lovell's proposal to start distributing Node.js for Linux in a way that enables ASLR (and thus makes non-JS-layer RCEs harder to exploit node#66951
29 likes · 3,035 views
Mentions
That JS supports await isn't nearly as much a problem as the fact that it defensively defers execution any time it encounters one. This hurts most badly for iterators. Files are usually read from disks in chunks of 64K bytes. If you present the data as an async byte iterator you'll get 65,534 defensive deferrals, and one actual wait. If you have ten layers of transformation it will be 655,350 defensive deferrals and 10 real waits. These defensive deferrals aren't so expensive in terms of CPU time, but they ARE very expensive in terms of wall clock time. All other work must be halted so the event loop code can run. Eventually synthetic deferrals dominate the wall clock cost of running the program. This isn't hypothetical either. Here's someone trying to build steam parsing on Node and finding that it's too slow to be useful: node#31979 The solution is stream iterators. They are sync when they can be and async when they must be: docs.bablr.org
My Pool Has 4 Threads, DNS Gets 2 In Node, the word "asynchronous" does not describe a single thing. Some calls really are handed to the kernel and come back when the operating system is ready; others are placed on one of four background threads and wait their turn. The second kind is libuv's work pool, its size is four, and that number appears in Node's documentation in a single aside: "its current default value". What led me to that number was my cover generator. In batch mode generate-hero.ts drains its queue with four workers — a hard-coded 4 inside the script — and each worker is one sharp call. In a separate run, in a separate process, my social posting script names nine external hosts in its source. Both are "asynchronous", both sit on their own four threads, and I had no idea how much of those four either one was holding. I wanted to measure this properly, once. The result was sharper than I expected: dns.lookup can never take more than two of those four threads, and neither libuv's documentation nor Node's mentions that limit. Who lands in the pool Node's command-line documentation spells out the list: all fs calls except the file watchers and the explicitly synchronous ones, asynchronous crypto calls such as crypto.pbkdf2, scrypt, randomBytes and generateKeyPair, all zlib calls except the explicitly synchronous ones, and dns.lookup. The warning that follows is fair: because the pool has a fixed size, if one of these takes a long time, other "seemingly unrelated" ones get slower. The sneakiest member of that list is dns.lookup, because you are usually not the one deciding to call it. In Node's lib/net.js, the connect path says it in one line: const lookup = options.lookup || dns.lookup;. So net.connect, http, https and fetch — undici, which sits underneath fetch, uses net.connect for plain HTTP and tls.connect for TLS, and both pass through that same line — resolve names through dns.lookup unless you supply your own resolver. The first step of every outbound HTTP request queues on the same four threads as your file reads. The dns.resolve* family is not on that list. Node's documentation draws the line clearly: those functions do not use getaddrinfo(3), they always query over the network, and they do not use libuv's threadpool. Keep that distinction in mind; we will come back to it with a measurement at the end. The lab: my own resolver, a fixed 300 ms Measuring against a real DNS server is pointless; if the latency moves, you cannot read the table. So I wrote a 49-line UDP server: it holds every incoming query for exactly 300 ms, then answers 127.0.0.2, sets TTL zero (no caching), and most importantly it counts how many distinct names are in flight at the same time. Instead of guessing concurrency from the client side, I let the resolver count it. The lab ran in a node:26-alpine container: Alpine 3.24, Node v26.13.0, libuv 1.52.1, aarch64, 14 cores. /etc/resolv.conf has one line: `nameserver 127.0.0.1. The client calls dns.lookup` for 24 distinct names at once, with family: 4, so each name sends a single A query and the resolver's A/AAAA behaviour stays out of the table. The numbers below are medians of three runs. Alpine uses musl; since the limit measured here lives in libuv, the resolver library does not change the result, but the nsswitch.conf argument later in this article applies to glibc setups. | UV_THREADPOOL_SIZE | Concurrent queries counted by the resolver | 24 names, total | |---|---|---| | unset (4) | 2 | 3,695 ms | | 1 | 1 | 7,414 ms | | 2 | 1 | 7,427 ms | | 3 | 2 | 3,703 ms | | 4 | 2 | 3,712 ms | | 6 | 3 | 2,480 ms | | 8 | 4 | 1,859 ms | | 16 | 8 | 934 ms | The second column is exactly half the pool size, rounded up. What amuses me most in this table is the relationship between the second and third rows. Going from one thread to two moves the total for 24 names from 7,414 ms to 7,427 ms — that is, nowhere. I doubled the threads and what I gained on the DNS side is smaller than the measurement noise. But going from two to three cuts the time in half. Adding one thread buys nothing once and a factor of two the next time. The reason sits in libuv's source, on line 46 of src/threadpool.c: static unsigned int slow_work_thread_threshold(void) { return (nthreads + 1) / 2; } libuv sorts work into three kinds: UV__WORK_CPU, UV__WORK_FAST_IO and UV__WORK_SLOW_IO. src/unix/getaddrinfo.c submits its request as UV__WORK_SLOW_IO; src/unix/fs.c submits every file operation as UV__WORK_FAST_IO, and user code calling uv_queue_work submits as UV__WORK_CPU. Slow-I/O work piles up in a separate queue and only a single "there is slow work" marker goes into the main queue. When a thread picks up that marker and starts running slow work, a counter goes up; if the counter has reached the threshold, the thread pushes the marker back to the tail of the queue and looks for other work. !Diagram So this is not an accident but a deliberate guard: it exists so a slow name lookup cannot swallow the whole pool and block file reads. I have no objection to the logic. My objection is that the limit is written down nowhere. libuv's own documentation covers the default, the absolute maximum, and that the pool is global and shared across all loops; for its scope it says "all file system operations, as well as getaddrinfo and getnameinfo requests". Not one sentence about the half-pool threshold. Node's UV_THREADPOOL_SIZE section does not have it either: there the advice is simply to set a value greater than 4. Follow that advice and DNS can use at most half of whatever you set. Write 8 and the ceiling on your name-resolution concurrency is 4. It is a ceiling, not an allocation: when there is no DNS work those slots do not sit idle, they take other work. Who starves whom The direction of the threshold matters, because it is asymmetric. Two scenarios, both with the pool at its default four threads: A) With 32 pending dns.lookup calls (each answered by my 300 ms resolver) I squeezed in one fs.readFile and one pbkdf2. The fs.readFile waited 0 ms, the pbkdf2 2 ms. Thirty-two names are queued and the file read goes straight through. The guard works. B) With 32 pending pbkdf2 calls — a computation that takes 208 ms on its own — I squeezed in one dns.lookup and one fs.readFile. The fs.readFile waited 1,719 ms, the dns.lookup 2,018 ms. The arithmetic of that second line lands exactly: 32 × 208 ms on four threads is a 1,664 ms queue, and the measurement is 1,719 ms. DNS's 2,018 ms comes from the same place — 1,719 ms of queue plus my resolver's fixed 300 ms, totalling 2,019 ms. The resolver answers in 300 ms while dns.lookup takes two seconds, and 85 percent of that has nothing to do with resolving a name. The lesson here runs the other way: some investigations that start with "our DNS is slow" are looking in the wrong place. What fills the pool is not name resolution but the crypto, compression or image work in the same process. The slow-I/O threshold stops DNS from choking everyone else; nothing stops everyone else from choking DNS. Where the threshold sits on my own pipeline The same question goes to my cover generator, since that is where it started. came from. generate-hero.ts makes a single sharp call: it rasterises the SVG at 200 dpi, fits it to 1200×630 and writes a PNG with compressionLevel: 9. On my Mac (Node v22.16.0, libuv 1.49.2, sharp 0.35.5, libvips 8.18.7, 14 cores) that call takes 75 ms on its own. On an idle pool an fs.readFile takes 0.2 ms. I started the same call N times concurrently and squeezed in a read of a real article file: | Concurrent covers | How long fs.readFile waited | |---|---| | 1 | 0 ms | | 2 | 0 ms | | 3 | 0 ms | | 4 | 9 ms (three runs: 1 – 31 ms) | | 8 | 62 ms (three runs: 56 – 69 ms) | The threshold is exactly at four. Up to three there is still a free thread in the pool, so the read never waits; the moment a fourth cover enters, it starts waiting. I will not lean on the 9 ms in the fourth row — across three runs it wandered between 1 and 31 ms, because the instant a thread frees up is a matter of chance. At eight the number is steady: about 60 ms. Something caught my eye here. My script does not only write two PNGs per article and exit; in --all mode it drains the queue with four workers, and that four is a constant I put there myself: Array.from({ length: 4 }, ...). The comment next to it says it "gains parallel I/O". So when I rebuild the covers for all 2,772 posts I fill the entire pool, and the fourth row of the table is exactly that point. When I wrote that 4 I did not know the pool was also four; the two numbers matching is a coincidence, what follows from it is not — during an --all run every fs read and every dns.lookup in the same process queues behind it. The absolute values are small, I know. What interests me is not the absolute value but that the threshold is visible at all. And one more thing: there is not a single line in my repository that mentions UV_THREADPOOL_SIZE. Not in the scripts, not in the systemd units, not in the workflows. I have been running on four threads and until today I had not chosen that deliberately. When exactly is the pool born Node's documentation says that setting process.env.UV_THREADPOOL_SIZE "is not guaranteed to work" and gives a reason: the pool would have been created "as part of the runtime initialisation much before user code is run". The warning itself is right; the reason does not hold on the Node in this lab. libuv's pool is lazy. The first line of uv__work_submit is `uv_once(&once, init_once)` — the pool is born when the first piece of work is submitted. We can count it from outside, because since libuv 1.50.0 the threads are named libuv-worker: # process is up but nothing has hit the pool yet $ ls /proc/<pid>/task/*/comm | xargs cat | grep -c libuv-worker 0 After a single dns.lookup, that same count returns whatever UV_THREADPOOL_SIZE says — 4 if you wrote 4, 128 if you wrote 128, for one request. That is precisely libuv's sentence about preallocating the maximum number of threads allowed. Five rows, same lab, on where the value is set: | Where it was set | Concurrency counted by the resolver | 24 names | |---|---|---| | nowhere (default 4) | 2 | 3,695 ms | | first line of the JS: process.env... = '16' | 8 | 930 ms | | the same line after one fs.readFile | 2 | 3,720 ms | | UV_THREADPOOL_SIZE=16 in the shell | 8 | 923 ms | | node --env-file=uv.env | 8 | 934 ms | The third row vindicates the documentation's warning, but for a different reason: the setting is not missed because the runtime built the pool early; it is missed because someone submitted work to the pool before you did. A library that reads a file with top-level await, or a dependency that calls randomBytes, quietly makes your setting irrelevant. So the advice stands: do not set this value from inside your code. The two honest paths in the table above are the shell environment and --env-file; both land before the first job. The bill for growing it The ceiling is 1024 (libuv 1.30.0 raised it from 128) and each thread wants an 8 MiB stack (since 1.45.0; before that it was the platform default). Readings from the container's /proc/<pid>/status after a single dns.lookup: | UV_THREADPOOL_SIZE | libuv-worker | VmSize | VmRSS | Total threads | |---|---|---|---|---| | unset (4) | 4 | 848 MiB | 61 MiB | 11 | | 8 | 8 | 880 MiB | 61 MiB | 15 | | 128 | 128 | 1,841 MiB | 61 MiB | 135 | | 1024 | 1024 | 9,021 MiB | 75 MiB | 1,031 | Going from four to eight adds 32 MiB to VmSize: four threads × 8 MiB, exact. At 1024 the virtual size reaches 9 GiB while resident memory stays at 75 MiB — the stacks are paged lazily, just as libuv's documentation says. So the thing to worry about is not RSS; it is virtual address space in an environment capped by ulimit -v, and a scheduler that now has 1,031 threads to deal with. My decision: for a process like my blog pipeline, which writes a couple of images per article and talks to five platforms, 16 is more than enough. Values in the hundreds only make sense in a process that genuinely makes hundreds of concurrent slow calls, and even there the real question is not the pool size but why those calls are concurrent. How to leave the limit behind entirely The cleanest exit is not to grow the pool but not to use it. dns.resolve4 runs on c-ares; the query happens on the event loop, not in the pool. I asked the same 24 names of the same 300 ms resolver, with the default four-thread pool: | Method | Concurrent | 24 names | |---|---|---| | dns.lookup | 2 | 3,695 ms | | dns.resolve4 | 24 | 312 ms | All twenty-four in flight, and the total is the duration of one query. More than an eleven-fold difference, and I never touched the pool. It is not free, of course. Node's documentation states the difference honestly: dns.resolve* does not read /etc/hosts, and it does not use nsswitch.conf and resolv.conf the way dns.lookup does. So there is no guarantee it gives the same answer as ping. In a container, service names written into /etc/hosts, mDNS, search domains — all of that disappears on this path. The middle ground the documentation points to is more sensible: resolve the name with dns.resolve() and use the address, or replace the lookup option that APIs such as socket.connect() and dgram.createSocket() accept with your own implementation — which is also exactly where a cache belongs. Questions to ask about your own setup - How many distinct hosts does your process talk to? Each one means a default dns.lookup, and they all share at most two threads. - Does the same process do image processing, zlib, pbkdf2 or heavy file reads? If so, what fills the pool is probably not DNS but those. - Is UV_THREADPOOL_SIZE set from inside the code? If you cannot prove no work hit the pool before that line, the setting may be ignored. - Do you know how many threads your pool opened with? On Linux, counting libuv-worker in /proc/<pid>/task/*/comm takes two seconds (libuv 1.50.0 and above); on macOS you need sample or vmmap. - If you have a slow-resolver problem, should the answer be dns.resolve? Yes if you have no dependency on /etc/hosts and nsswitch; otherwise your own lookup. - If your traffic goes through global fetch, how do you hand it a lookup? fetch takes no such option; the path runs through undici's dispatcher — setGlobalDispatcher(new Agent({ connect: { lookup } })). - You opened eight worker_threads and assume you have eight pools? The thread count and the queues in threadpool.c are static: there is one pool per process. Eight workers share the same four threads and the same two DNS slots. - What happens when your nameserver dies? A getaddrinfo that has already started cannot be cancelled — uv__work_cancel can only pull work that has not begun off the queue — so two threads stay pinned for as long as `timeout × attempts in resolv.conf` says. Limits without counters Not one measurement in this article produces an alert. There is no counter that goes up when the pool fills. The tooling is not entirely absent: perf_hooks has a 'dns' entry type that tells you how long dns.lookup took, and the gap between the node.threadpoolwork.async and .sync traces gives you the time spent queueing. But the first one does not tell you why it took that long, and the second is never emitted by the pool's two busiest customers — dns.lookup and fs — because neither goes through the code that emits it. The limit is there; in practice the only evidence is in your p99. I ran into the same shape in the [CPU quota article](mustafaerbay.com.tr): a process using seventeen percent of its quota, throttled half a million times in ten days, invisible to every dashboard that looks at averages. In the [keepalive article](mustafaerbay.com.tr) the setting was right but almost no application read it. Here is a third variant: the setting is documented, it is recommended, and half of the recommendation is lost on the way — and the place it is lost is written on line 46 of the source. Two threads out of four. If you did not choose that deliberately, libuv chose it for you. Official Sources - Node.js — UV_THREADPOOL_SIZE (CLI documentation) - Node.js — DNS: Implementation considerations - libuv — src/threadpool.c (v1.x branch) - libuv — Thread pool work scheduling documentation - libuv — src/unix/getaddrinfo.c (v1.x branch) - Node.js — lib/net.js (main branch)
I Locked Node Into One Directory — Five Doors Stayed Open 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](mustafaerbay.com.tr), 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. !Diagram 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 readFileSync that lost its way, a misconfigured backup script, a library that decides it would like to read /etc too. It does not help against an attacker who can run code inside the process. - Which grant am I handing over? --allow-child-process, --allow-addons and --allow-worker lower the wall the moment they are given. Node says so itself: passing each of the four in turn (--allow-wasi included) printed the same SecurityWarning line — "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](mustafaerbay.com.tr): 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. Official Sources - Node.js — Permissions (permission model documentation) - Node.js — Command-line API (--permission and the --allow-* flags) - nodejs/node — "permission: propagate permission model flags on spawn" patch - Node.js v22.x — Permissions (the 22 line's own documentation)
🔏 Time to work on Node.js macOS notarization and finally fix the root cause: node#67140
Your GitHub Actions Jobs May Now Run on AWS | Dev News Five developer stories and some updates in about four minutes: what happened, why it matters, and what to do. 1. GitHub Actions: eligible Linux jobs on standard 2- and 4-core runners may now run on AWS as well as Azure (automatic on Free, Pro and Team; Enterprise Cloud opts in). They can use new outbound IPs, so allowlists built on Azure ranges, including Entra Conditional Access, can block them: sync with the actions list from api.github.com/meta. Included Actions usage is now a dollar amount ($12 Free, $18 Pro/Team, $300 Enterprise Cloud a month), and Copilot cloud sandboxes draw from it, with billing from November. macos-14 jobs fail during brownouts Oct 29 and Oct 30, 14:00 to 24:00 UTC; the image retires Nov 2. 2. CISA added Linux kernel CVE-2026-46300 ("Fragnesia", per Red Hat) to its Known Exploited Vulnerabilities list, due Oct 31, saying it can be chained with PHP2Shell to plant web shells and run commands as root. Patch PHP (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35) and the host kernel (fixed upstream in 5.10.257, 5.15.208, 6.1.174, 6.6.141, 6.12.91, 6.18.33, 7.0.10, or your distro's update). PHP2Shell's federal deadline is Oct 29. 3. Jenkins fixed 15 core vulnerabilities (13 High), including a setup-wizard race that lets an unauthenticated attacker create an admin account on a new instance. Upgrade to 2.588 or LTS 2.580.2. 4. Apache Kafka disclosed 12 vulnerabilities. A Kafka 4.1–4.3.0 broker using SASL/OAUTHBEARER with no JWKS endpoint URL (the default) skips signature checks and accepts unsigned tokens (CVE-2026-110985). Kafka Streams apps and consumers using ListSerde can be crashed by a crafted record. Set sasl.oauthbearer.jwks.endpoint.url, then upgrade brokers and client libraries to 4.2.2, 4.3.2 or 4.4.0. 5. PyTorch 2.15 drops Python 3.10, needs NVIDIA driver 580+ and a Turing (sm_75) or newer GPU for the default CUDA wheels, needs macOS 15 for MPS, and writes distributed checkpoints 2.14 can't load. Pin torch. Updates: Node.js 26 'Lithium' became LTS on Oct 28 (26.13.0); the first Node 27 alpha slipped and is expected shortly. Tailscale 1.104.4 fixes its SSH and Serve flaws. On Oct 30, Amazon Pinpoint's console and engagement features end, and Cloud Run functions decommission Node.js 20. ⏱ Chapters 0:00 Intro 0:13 Today's rundown 0:43 GitHub Actions jobs may now run on AWS 1:29 PHP2Shell gets root: a kernel bug joins KEV 1:59 Jenkins fixes 15 core flaws 2:23 Kafka brokers can accept unsigned tokens 2:57 PyTorch 2.15 drops Python 3.10 3:23 Updates 3:48 Sign-off 📰 Sources GitHub Actions multi-cloud runners: github.blog Copilot cloud and the dollar-based Actions pool: github.blog GitHub Docs, included usage: docs.github.com macos-14 retirement: github.blog CISA KEV catalog (feed): cisa.gov CVE-2026-46300 record: cveawg.mitre.org Red Hat on CVE-2026-46300: access.redhat.com Palo Alto advisory: security.paloaltonetworks.com Jenkins Security Advisory 2026-10-28: jenkins.io Apache Kafka CVE list: kafka.apache.org Kafka advisories on oss-security (Oct 28): openwall.com PyTorch 2.15 release notes: github.com/pytorch/pytorch/releases/tag/v2.15.0 Node.js 26.13.0: nodejs.org Node.js 27 alpha release PR: node#67062 Tailscale security bulletins: tailscale.com Amazon Pinpoint end of support: docs.aws.amazon.com Cloud Run functions runtime support: cloud.google.com Just Known Daily: developer news, every day. From unknown to known. A channel by Just Unknown. #technews #github #devops #cybersecurity #pytorch #developers
This change makes inflateSync 57-72% and deflateSync 12% faster node#66864
This change makes inflate 350% faster on small buffer, and 40-47% faster on large buffer. node#66863
yeah, I'm missing Deno.serve the most :-( Maybe one day: node#63096
자바스크립트는 브라우저 전용이라는 상식, 완전히 틀렸습니다. | nodejs/node 자바스크립트는 브라우저 전용이라는 상식, 완전히 틀렸습니다. | nodejs/node GitHub의 nodejs/node는 바로 Node.js! 오픈소스 크로스플랫폼 자바스크립트 런타임으로, 어디서든 자바스크립트를 실행하게 해줍니다. 핵심은 두 가지! 첫째, 매년 4월 새 메이저 버전을 내는 규칙적인 릴리스 체계. 둘째, 무려 30개월간 지원되는 LTS로 보안과 안정성까지 책임지죠. 한줄평: 자바스크립트 생태계의 심장, 아직 안 써봤다면 지금 만나보세요! Repo: nodejs/node #굿리포 #GitHub #오픈소스
Huge fan of @lovell's proposal to start distributing Node.js for Linux in a way that enables ASLR (and thus makes non-JS-layer RCEs harder to exploit node#66951