Skip to content

permission: process.permission.drop() aborts (SIGABRT) when two --allow-fs-read grants collide at their first wildcard #66901

Open

Description

@itxcrusher

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.js

How often does it reproduce? Is there a required condition?

Every time, given both conditions:

  1. two --allow-fs-read grants identical up to and including their first *, and
  2. 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

  1. nhinata commented on Oct 22, 2026

    @nhinata

    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.

  2. itxcrusher commented on Oct 22, 2026

    @itxcrusher
    Author

    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:

    02bae370c and #66556 are the same change. The commit message carries PR-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.drop exists there, #66556 does not, and it does not abort.

    The assertion's presence is not the differentiator, reachability is. CHECK(!path_prefix.empty()) is at fs_permission.h:59 in 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: GrantAccess only inserts when !Lookup(path), so the colliding insert never happens while flags are applied, and #66556 moved the granted_paths_ push_back out of that branch so RebuildTree now 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 was main at 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions