Repository navigation
permission: process.permission.drop() aborts (SIGABRT) when two --allow-fs-read grants collide at their first wildcard #66901
Description
Version
v26.12.0
Platform
Windows 11 x64. The code path is platform independent, so Linux and macOS are expected to behave the same.
Subsystem
permission
What steps will reproduce the bug?
Two --allow-fs-read grants whose text is identical up to and including the first *, plus any process.permission.drop() call, abort the process.
mkdir -p /tmp/sig
cat > /tmp/sig/app.js <<'EOF'
process.permission.drop('fs.read', process.argv[1]);
console.log('survived');
EOF
node --permission \
--allow-fs-read='/tmp/sig/*' \
--allow-fs-read='/tmp/sig/*.js' \
/tmp/sig/app.jsHow often does it reproduce? Is there a required condition?
Every time, given both conditions:
- two
--allow-fs-readgrants identical up to and including their first*, and - any
process.permission.drop('fs.read', ...)call.
Startup on its own is fine. Measured controls:
| configuration | exit code |
|---|---|
two colliding grants, no drop() |
0 |
two colliding grants + drop(entrypoint) |
134 |
single grant + drop(entrypoint) |
0 |
two colliding grants + drop() on an unrelated path |
0 |
two non-colliding grants + drop(entrypoint) |
0 |
collision on the --allow-fs-write list + drop('fs.read', ...) |
0 |
The last row is consistent with only the dropped scope's tree being replayed. v22.23.3 is unaffected because process.permission.drop does not exist on that line.
What is the expected behavior? Why is that the expected behavior?
process.permission.drop() should either drop the requested scope or throw a catchable error. It should not abort the process.
The grant combination is a reasonable thing to write. The permission documentation encourages wildcard grants and states that characters after a * are ignored, so --allow-fs-read='/tmp/*' --allow-fs-read='/tmp/*.js' reads as redundant but harmless rather than fatal, and nothing indicates it becomes fatal once the application calls drop().
What do you see instead?
SIGABRT, exit code 134:
Assertion failed: !path_prefix.empty()
at src/permission/fs_permission.h:62
in node::permission::FSPermission::RadixTree::Node::CreateChild(std::string_view)
----- JavaScript stack trace -----
1: drop (node:internal/process/permission:66:16)
2: /tmp/sig/app.js:1:20
CHECK is unconditional in release builds, so application code cannot catch this. A process that drops privileges after startup, which is the intended use of the API, terminates.
Additional information
Why startup is safe but drop() is not.
FSPermission::GrantAccess only inserts a path when !Lookup(path). /tmp/sig/*.js is already covered by /tmp/sig/*, so the colliding insert never happens while the flags are applied and the tree is built correctly.
Commit 02bae37 ("permission: keep explicit fs grants when dropping a covering directory", 2026-10-13) moved the granted_paths_ push_back out of that !Lookup(path) branch so every explicit grant is recorded even when covered. RebuildTree then replays the whole recorded list unconditionally, so the first drop() inserts both colliding grants into a fresh tree and CreateChild trips its own CHECK. That makes this a regression introduced alongside the grant-retention change rather than longstanding behaviour.
Where it aborts.
Node::CreateChild reaches CHECK(!path_prefix.empty()) when the remaining prefix is empty, which happens when the second grant diverges only after a * that both grants share. Since Lookup never descends into a wildcard_child, the subtree being built past the first * is unreachable to lookups, so the insert that aborts appears to be doing no useful work.
Possible directions, offered as context rather than as a tested patch, since the right choice interacts with how granted_paths_ is replayed: either skip insertion once a wildcard has been consumed, given that everything after the first * is ignored by lookups, or have CreateChild return early on an empty path_prefix instead of asserting.
I considered whether this belonged in the security process and concluded it does not: it requires in-process application code to call drop(), and it does not meet the denial-of-service criteria in the threat model, which require the behaviour to be directly exploitable by an untrusted source without application mistakes. Filing publicly on that basis. Happy to move it if maintainers see it differently.
process.permission.drop is documented as Stability 1.1 - Active Development.
Activity
nhinata commentedon Oct 22, 2026
Reproduced on macOS arm64 with the official binaries:
The other five rows of your table match exactly. v26.12.1 (which includes the security fix to the permission radix tree) still aborts. The assertion is now at fs_permission.h:59.
itxcrusher commentedon Oct 22, 2026
Thanks for running this across platforms, that is more useful than the original report.
Two things to add, both verified against the repo rather than inferred:
02bae370cand #66556 are the same change. The commit message carriesPR-URL: https://github.com/nodejs/node/pull/66556, so there is one suspect here, not two. Your v24.22.1 row is the control that settles it:process.permission.dropexists there, #66556 does not, and it does not abort.The assertion's presence is not the differentiator, reachability is.
CHECK(!path_prefix.empty())is atfs_permission.h:59in v26.11.1, v26.12.0 and v26.12.1 alike, and v26.11.1 does not abort. That matches the mechanism in the report:GrantAccessonly inserts when!Lookup(path), so the colliding insert never happens while flags are applied, and #66556 moved thegranted_paths_push_backout of that branch soRebuildTreenow replays covered grants into a fresh tree.Correction to my original report: I cited
fs_permission.h:62, you cite:59. Yours is right for every release. 62 wasmainat the time I measured, before the permission radix-tree fix landed publicly on 2026-10-21 and shifted the line. Worth stating so nobody chases line 62.Also confirming your v26.12.1 observation from source: that tag does contain the radix-tree fix and still aborts, so the two are unrelated defects.
permission: make inserting an already-granted path idempotent
permission: make inserting an already-granted path idempotent