Use repeated Node permission allowlists to restrict a file-reading script

John Burns

A small Node utility that reads one configuration file can often read every file available to its service account. Containers and Unix permissions still matter, but they do not express the narrower contract: this process needs its own script, one input file, and nothing else. Node’s Permission Model provides a process-level boundary for that contract.

The important detail is how the file-read allowlist is supplied. In a clean test with Node v26.8.1, one --allow-fs-read flag for the script allowed Node to start but denied the separate input file. Repeating --allow-fs-read once for each required path allowed the same program to complete. Treat each path as its own capability rather than composing a comma-separated list into a single flag.

This is useful for scheduled importers, report generators, and single-purpose command-line tools. It is not a substitute for a dedicated account, filesystem ownership, container isolation, or a review of dependencies. It is a narrow control that makes an accidental read outside the intended input set fail at runtime.

Start with a deliberately small file contract

The Permission Model is enabled with --permission. Once it is active, Node restricts filesystem access unless it is permitted with --allow-fs-read or --allow-fs-write. Start with the smallest useful contract instead of granting an application directory wholesale.

Create a disposable directory and a program that reads the path it receives on the command line:

workdir="$(mktemp -d)"
printf '%s\n' 'expected input' > "$workdir/input.txt"
printf '%s\n' 'should not be readable' > "$workdir/unlisted.txt"

cat > "$workdir/read-one-file.js" <<'EOF'
const fs = require('node:fs');
console.log(fs.readFileSync(process.argv[2], 'utf8').trim());
EOF

The program is intentionally ordinary. readFileSync() is not replaced with a special API, and the access rule is not embedded in the source. That separation makes the same script usable under a narrowly scoped service unit, a CI job, or an interactive maintenance command.

For a real utility, list the program file, the input files, and any directories Node must traverse. A script may also need permission to load its own dependencies. Begin with the entry point and required data, then add only paths named by a denied-access error that you understand. Do not respond to the first failure by changing the allowlist to *.

Prove the denied case before granting more access

First allow only the JavaScript entry point and ask it to read the input file:

node --permission \
  --allow-fs-read="$workdir/read-one-file.js" \
  "$workdir/read-one-file.js" "$workdir/input.txt"

On the Node v26.8.1 test run, that command exited with status 1. Node reported ERR_ACCESS_DENIED, identified the permission as FileSystemRead, and named the input file as the denied resource. That is the failure a release gate or service operator needs: the process started, but an undeclared file read did not quietly succeed.

The error is also a diagnostic tool. Check that the denied resource is genuinely required before adding it. A missing read permission for a CA bundle, a template, or a dependency directory may be legitimate. A request for a home directory, a broad temporary directory, or an unrelated configuration tree is a reason to inspect the program and its startup environment.

Keep the negative check in a test fixture. A policy that is never exercised can drift into a decorative command-line flag, especially after a dependency update adds a new file read.

Add one allow flag for each required path

Node documents --allow-fs-read as a repeatable option. Give the entry point and the input file separate flags:

node --permission \
  --allow-fs-read="$workdir/read-one-file.js" \
  --allow-fs-read="$workdir/input.txt" \
  "$workdir/read-one-file.js" "$workdir/input.txt"

The v26.8.1 retest printed:

expected input

and exited with status 0. The same process remained unable to read unlisted.txt because that file was never granted. Test that assertion directly rather than assuming a successful permitted read proves the boundary:

node --permission \
  --allow-fs-read="$workdir/read-one-file.js" \
  --allow-fs-read="$workdir/input.txt" \
  "$workdir/read-one-file.js" "$workdir/unlisted.txt"

Expect an ERR_ACCESS_DENIED result and a nonzero exit status. Do not put two unrelated pathnames in one comma-separated --allow-fs-read value. The tested setup used repeated flags, matching the form shown in the Node documentation. Repetition is also easier to review in a systemd unit or CI definition: each privilege is visible as a separate argument.

A directory may be a better grant when the tool processes a controlled set of files rather than one static filename. Make that directory as specific as possible. A permission for an application-owned import directory has a clear operational purpose; a permission for a general temporary directory permits many unrelated inputs and makes the policy much harder to audit.

Put the policy where the process is launched

For a service, keep the allowlist in the unit’s ExecStart line or an environment file with appropriately restricted ownership. This example uses placeholders; replace them with application-owned paths rather than copying a host layout.

[Service]
ExecStart=/usr/bin/node --permission \
  --allow-fs-read=/srv/report-reader/read-one-file.js \
  --allow-fs-read=/etc/report-reader/input.json \
  /srv/report-reader/read-one-file.js /etc/report-reader/input.json

A long ExecStart command can be awkward to maintain. A wrapper owned by the deployment process is reasonable when it keeps the allowlist reviewable, but do not let the wrapper assemble permissions from untrusted input. The file paths are part of the security policy and should be versioned or controlled alongside the service definition.

The model has boundaries that deserve explicit consideration. It limits Node’s access checks; it does not stop a native dependency, a subprocess, or a compromised service account from being risky in every possible way. Node’s documentation also describes feature-specific constraints. Keep operating-system file permissions, an unprivileged account, and outbound-network controls in place. If the application needs child processes, network access, native extensions, or runtime-loaded code, test those requirements independently before treating the process as confined.

Make the check part of a release decision

A compact shell test can protect the intended boundary during a deployment build. It should prove one allowed read and one denied read:

set -eu
node --permission \
  --allow-fs-read="$workdir/read-one-file.js" \
  --allow-fs-read="$workdir/input.txt" \
  "$workdir/read-one-file.js" "$workdir/input.txt" \
  | grep -Fx 'expected input'

if node --permission \
  --allow-fs-read="$workdir/read-one-file.js" \
  --allow-fs-read="$workdir/input.txt" \
  "$workdir/read-one-file.js" "$workdir/unlisted.txt"; then
  printf '%s\n' 'unlisted read unexpectedly succeeded' >&2
  exit 1
fi

Use a test-specific directory and synthetic contents. A CI transcript does not need production paths, secrets, or configuration values to prove the rule. Record the Node version in the job output, because permission behavior and command-line support are runtime-dependent. The current Node release page is the right place to check for relevant changes before an upgrade; the API documentation defines the permission flags and their constraints.

The result is a testable file-read contract: the script can read the exact input it requires, and an adjacent unlisted file is rejected. That does not make a Node process invulnerable, but it turns an overly broad filesystem assumption into a failure that can be caught before deployment.

Sources