Skip to content

feat: add plugin directory trust guard to harden shared plugin caches#7308

Open
aknownuser wants to merge 6 commits into
masterfrom
security-plugin-dir-trust-guard
Open

feat: add plugin directory trust guard to harden shared plugin caches#7308
aknownuser wants to merge 6 commits into
masterfrom
security-plugin-dir-trust-guard

Conversation

@aknownuser

Copy link
Copy Markdown

Summary

Nextflow loads and executes plugin code from the local plugin cache ($id-$version) without re-verifying its origin when the directory already exists — PluginUpdater.load0() short-circuits the download (and therefore the sha512sum/CompoundVerifier path) whenever the cached directory is present, and PluginsFacade.isAllowed() only matches plugin IDs, not content.

On a shared, writable plugin cache (NXF_PLUGINS_DIR or $NXF_HOME/plugins), a lower-trust local user can pre-populate an official pinned plugin coordinate (e.g. nf-amazon@3.10.0) with attacker-controlled code that then runs inside a victim's Nextflow process.

The plugin cache is expected to be a per-user, private trust boundary (default $HOME/.nextflow/plugins). This PR adds a defense-in-depth guard that verifies a plugin directory is trusted before loading it, addressing the exact precondition of the issue — write access to a trusted cache — with no network calls, no checksums, and no persisted state (works offline).

Changes and Reasoning

New PluginSecurity helper (modules/nf-commons/.../plugin/PluginSecurity.groovy)
A directory is considered trusted only when it is owned by the current user (or root) and not writable by group or others. This accepts the two legitimate deployment shapes — a private user cache (e.g. 0700) and an admin-managed read-only shared cache (e.g. root:root 0755) — while rejecting an attacker-owned directory or a group/world-writable cache.

The both-conditions check matters: a permissions-only check would miss the reported scenario, where the poisoned directory was attacker:attacker 755 (not group/other-writable) — ownership is what made it dangerous.

Call sites

  • PluginsFacade.init(...) — guards the plugins root at setup (both init overloads), so startup-loaded plugins are covered.
  • PluginUpdater.load0() — guards each $id-$version directory immediately before loadPluginFromPath, covering cache reuse and the attacker-owned-subdir case.

New env var NXF_PLUGINS_STRICT_MODE (warn | strict | off)

  • warn (default) — logs a warning and continues (zero breakage for existing setups).
  • strict — aborts the run with AbortOperationException.
  • off — disables the check.

The guard is a no-op on filesystems without POSIX permissions and in dev mode (where plugins load from the source tree, not the cache).

Compatibility / rollout
Default warn means no existing deployment breaks; the default $HOME/.nextflow/plugins (user-owned) is silent. strict is opt-in now; a future stable release can promote the default.

Testing

New PluginSecurityTest (16 cases) and existing plugin tests pass:

  • ✅ private 0700 / read-only 0755 → accepted in all modes
  • ✅ group/world-writable → warns in warn, throws in strict
  • off mode → never throws
  • ✅ null dir / non-POSIX FS → no-op
  • ✅ env-var mode resolution (warn default, strict, off)
  • :nf-commons:test full suite — no regression in PluginsFacadeTest / PluginUpdaterTest

End-to-end (dev launcher, NXF_PLUGINS_MODE=prod):

Scenario Result
strict + 0777 cache Aborts — workflow does not run
warn + 0777 cache Warns, workflow proceeds
strict + private 0700 cache Silent, workflow proceeds

Documentation

  • docs/reference/env-vars.mdx — new NXF_PLUGINS_STRICT_MODE entry.
  • docs/plugins/using-plugins.mdx — security note in the Caching section (keep the cache private; supported shared pattern is admin-owned read-only).

Nextflow loads and executes plugin code from the local plugin cache
($id-$version) without re-verifying its origin when the directory already
exists. On a shared, writable plugin cache (NXF_PLUGINS_DIR or
$NXF_HOME/plugins), a lower-trust local user can pre-populate an official
pinned plugin coordinate with attacker-controlled code that then runs in
the victim's Nextflow process.

The plugin cache is expected to be a per-user, private trust boundary
(default $HOME/.nextflow/plugins). This adds a defense-in-depth guard that
verifies a plugin directory is trusted before loading it: the directory
must be owned by the current user or root AND not writable by group or
others. This accepts the two legitimate shapes (private user cache; an
admin-managed read-only shared cache) while rejecting an attacker-owned or
group/world-writable directory.

Behaviour is controlled by NXF_PLUGINS_STRICT_MODE: warn (default) logs a
warning and continues, strict aborts the run, off disables the check. The
check is a no-op on filesystems without POSIX permissions and in dev mode
(where plugins load from the source tree, not the cache).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Oussama El Azizi <oussama.elazizi@seqera.io>
@netlify

netlify Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploy Preview for nextflow-docs ready!

Name Link
🔨 Latest commit 54b91cb
🔍 Latest deploy log https://app.netlify.com/projects/nextflow-docs/deploys/6a549afe680d6c0008f46747
😎 Deploy Preview https://deploy-preview-7308--nextflow-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@aknownuser aknownuser marked this pull request as ready for review July 8, 2026 09:46
@aknownuser aknownuser requested a review from a team as a code owner July 8, 2026 09:46
Comment thread modules/nf-commons/src/main/nextflow/plugin/PluginSecurity.groovy Outdated
Comment thread modules/nf-commons/src/test/nextflow/plugin/PluginSecurityTest.groovy Outdated
aknownuser and others added 2 commits July 8, 2026 15:32
Address review feedback: the mode was read verbatim, so typos, different
case or a blank value would silently behave as 'warn'. Normalise the value
(lower-case and trim) and validate it against the known modes, logging a
warning on an unrecognised value instead of ignoring it silently. Add test
coverage for normalisation and the invalid/blank fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Oussama El Azizi <oussama.elazizi@seqera.io>
Address review feedback: existing tests only exercised the permission
branch. Extract the trust decision into a pure isUntrusted(perms, owner,
currentUser) helper so the owner branch can be tested deterministically -
a temp dir in a unit test is always owned by the current user (and would
be root-owned under a root CI runner), so it cannot reach that branch via
real files. Add a data-driven test covering owned-by-me, root-owned,
untrusted-owner (the reported scenario) and group/world-writable cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Oussama El Azizi <oussama.elazizi@seqera.io>
@aknownuser aknownuser requested a review from jorgee July 8, 2026 13:47

@pditommaso pditommaso left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trust-guard's intent is sound and the warn/strict/off plumbing is clean. My concern is the trust predicate itself: it produces false positives in the exact deployment shapes Nextflow is most used in — spurious warnings on every run in the default warn mode, and aborted runs under NXF_PLUGINS_STRICT_MODE=strict. Requesting changes to tighten the predicate, plus a note on a stronger long-term direction.

Blocking — false positives

  1. Group-writable check breaks the umask-002 defaultPluginSecurity.isUntrusted (PluginSecurity.groovy:94). On stock Ubuntu/Debian/RHEL/Fedora regular-user sessions (umask 002, user-private-group), FilesEx.mkdirs(root) creates ~/.nextflow/plugins as rwxrwxr-x. The guard flags the directory Nextflow just created itself — every run/config/info/log/clean, and every freshly-downloaded plugin dir (PluginUpdater.groovy:406). A dir the user owns is theirs to make group-writable; the threat is a foreign writer.

  2. Owner compared by name, not UIDPluginSecurity.groovy:126. When the running UID has no /etc/passwd entry (containers, OpenShift, K8s, arbitrary-UID HPC — very common for Nextflow), System.getProperty('user.name') returns ? while attrs.owner().name resolves to the numeric UID string. Mismatch → the user's own private cache is flagged. Same issue under NFS/LDAP/AD name resolution.

  3. Root running against a user-owned cachePluginSecurity.groovy:95. sudo nextflow … with the user's own 0700 cache: me = root, owner = alice, so alice != me && alice != 'root' → flagged.

  4. Only literal root is exempted, but the javadoc promises admin-managed shared cachesPluginSecurity.groovy:95. A read-only shared cache owned by a service account (e.g. nextflow:nextflow 0755) is rejected for every non-admin user. Under site-wide strict, every nextflow run aborts with "refusing to load plugins" — the exact deployment the class doc says is supported.

  5. Per-plugin invocation floods duplicate warningsPluginUpdater.groovy:406. checkTrustedDir runs once per plugin/dependency in load0, so one underlying condition emits N identical warnings per run, and getMode() re-reads the env and re-emits the "Invalid NXF_PLUGINS_STRICT_MODE" warning N times too.

Suggested fixes (cheap, keep it offline-safe)

  • Compare owner by UID, not name → fixes #2 and #3.
  • Treat group/other-write as untrusted only when the owner isn't you → kills the umask-002 storm (#1).
  • Make the trusted-owner set configurable, or trust a read-only dir regardless of owner → #4.
  • Warn once per run, not per plugin → #5.

Better long-term strategy

Worth noting for direction (not required in this PR): download-time integrity is already covered — the registry serves sha512sum (HttpPluginRepository.groovy:220) and pf4j's CompoundVerifier checks the archive on download. This guard's real job is the post-download cache-poisoning window.

A load-time "checksum vs registry" would defend that window but requires re-fetching a trusted baseline from the registry on every run — which breaks offline/air-gapped use (a documented, supported mode) and makes registry availability a hard runtime dependency.

The genuinely stronger answer to supply-chain integrity is a committed lockfile with pinned plugin hashes, verified offline at load (the go.sum / package-lock.json model): the trusted baseline lives in the pipeline repo under version control, so it survives cache poisoning and registry compromise and silent drift, with no network. That's a separate, ADR-worthy feature — but it's the direction I'd aim for. This directory guard is a fine cheap complement in the meantime, once the false positives above are fixed.

Address review feedback: the trust predicate flagged legitimate mainstream
deployments. Rework it to flag a directory only when a non-trusted
principal can write to it:

- Drop the group-write check: a self-owned, group-writable dir (rwxrwxr-x
  created under the common umask 002 with a user-private group) is now
  trusted. Only world-writable (others-write) remains a risk for a
  trusted owner. Fixes spurious warnings/aborts on every run.
- Compare owner by UID instead of name, so containers/OpenShift/
  arbitrary-UID/NFS/LDAP (where user.name is '?') no longer flag the
  user's own cache.
- When running as root (uid 0), trust any owner but still flag a
  world-writable cache (fixes sudo against a user-owned 0700 cache).
- Add NXF_PLUGINS_TRUSTED_OWNERS (UIDs or names) so an admin-managed
  read-only cache owned by a service account is trusted.
- Warn at most once per offending directory, and emit the invalid-mode
  warning once, so a multi-plugin run no longer floods duplicates.
- Fail open (skip the check) when owner/current UID cannot be determined.

World-writable directories and foreign-owned directories are still
refused, so the original PoC (root-owned 777 root + attacker-owned 755
subdir) remains caught. Update docs and tests accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Oussama El Azizi <oussama.elazizi@seqera.io>
@aknownuser aknownuser requested a review from pditommaso July 8, 2026 15:50
@aknownuser

Copy link
Copy Markdown
Author

Yes the long term strategy you suggested is the ideal situation, an ADR for that would be perfect!

I have addressed the false positives you detected. Let me know if you think this still needs work.

You may have seen that this setup is opt-in, which is made this way to ovoid any breaking change to previous usage of nextflow.

@christopher-hakkaart christopher-hakkaart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding some style alignment suggestions.

Comment thread docs/plugins/using-plugins.mdx
Comment thread docs/plugins/using-plugins.mdx
aknownuser and others added 2 commits July 13, 2026 09:59
Co-authored-by: Chris Hakkaart <chris.hakkaart@seqera.io>
Signed-off-by: aNewUser <oussamaelazizi.opt@gmail.com>
Co-authored-by: Chris Hakkaart <chris.hakkaart@seqera.io>
Signed-off-by: aNewUser <oussamaelazizi.opt@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants