diff --git a/docs/plugins/using-plugins.mdx b/docs/plugins/using-plugins.mdx
index 82a370749c..be4460d4ce 100644
--- a/docs/plugins/using-plugins.mdx
+++ b/docs/plugins/using-plugins.mdx
@@ -56,6 +56,12 @@ Plugin declarations in Nextflow configuration files are ignored when specifying
When Nextflow downloads plugins, it caches them in the directory specified by `NXF_PLUGINS_DIR` (`$HOME/.nextflow/plugins` by default).
+:::warning
+The plugin cache should be a private, per-user directory that other users cannot write to. Nextflow loads and executes cached plugin code without re-verifying its origin. If the cache is writable by other users (or owned by another user), a lower-trust user can replace the cached code with their own, and that code then runs inside your Nextflow process.
+
+Keep `NXF_PLUGINS_DIR` owned by you and not world-writable. A group-writable directory owned by you, as created under the common umask 002, is fine. If you need a shared cache, use a directory owned by an administrator or a dedicated service account that regular users cannot modify, and add that owner to [`NXF_PLUGINS_TRUSTED_OWNERS`][env-vars]. Use [`NXF_PLUGINS_STRICT_MODE`][env-vars] to control whether Nextflow warns about or refuses to load plugins from an untrusted directory.
+:::
+
## Offline usage
When running Nextflow in an offline environment, any required plugins must be downloaded and moved into the offline environment prior to any runs.
@@ -74,6 +80,7 @@ To use Nextflow plugins in an offline environment:
Nextflow will attempt to download newer versions of plugins if their versions are not set. See [Identifiers][using-plugins-identifiers] for more information.
:::
+[env-vars]: ../reference/env-vars
[install-standalone]: ../install#standalone-distribution
[using-plugins-config]: ./using-plugins#configuration
[using-plugins-identifiers]: ./using-plugins#identifiers
diff --git a/docs/reference/env-vars.mdx b/docs/reference/env-vars.mdx
index a4b4b1a3c3..7d7aba6106 100644
--- a/docs/reference/env-vars.mdx
+++ b/docs/reference/env-vars.mdx
@@ -216,6 +216,18 @@ The path where the plugin archives are loaded and stored (default: `$NXF_HOME/pl
Specifies the URL of the plugin registry used to download and resolve plugins. This allows using custom or private plugin registries instead of the default public registry.
+##### `NXF_PLUGINS_STRICT_MODE`
+
+
+
+Controls how Nextflow reacts when the plugins directory (`NXF_PLUGINS_DIR`) or a cached plugin directory is not a trusted location — i.e. it is owned by a user other than the one running Nextflow (compared by UID; `root` and any owner listed in `NXF_PLUGINS_TRUSTED_OWNERS` are trusted) or it is world-writable. Such a directory could be modified by other users, allowing untrusted plugin code to run in your Nextflow process. Allowed values are `warn` (default, logs a warning and continues), `strict` (aborts the run) and `off` (disables the check). The check is skipped on filesystems that do not support POSIX permissions, and when running as `root` only a world-writable directory is flagged.
+
+##### `NXF_PLUGINS_TRUSTED_OWNERS`
+
+
+
+Comma-separated list of additional owners (numeric UIDs or user names) that are trusted to own the plugins directory or cached plugin directories, beyond the current user and `root`. Use this to allow an admin-managed shared plugin cache owned by a service account (e.g. `NXF_PLUGINS_TRUSTED_OWNERS=nextflow`) without triggering [`NXF_PLUGINS_STRICT_MODE`](#nxf_plugins_strict_mode).
+
##### `NXF_PLUGINS_TEST_REPOSITORY`
diff --git a/modules/nf-commons/src/main/nextflow/plugin/PluginSecurity.groovy b/modules/nf-commons/src/main/nextflow/plugin/PluginSecurity.groovy
new file mode 100644
index 0000000000..90ee0c7c77
--- /dev/null
+++ b/modules/nf-commons/src/main/nextflow/plugin/PluginSecurity.groovy
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2013-2026, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.plugin
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.PosixFileAttributeView
+import java.nio.file.attribute.PosixFileAttributes
+import java.nio.file.attribute.PosixFilePermission
+import java.nio.file.attribute.PosixFilePermissions
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+import nextflow.SysEnv
+import nextflow.exception.AbortOperationException
+/**
+ * Validate that a plugin directory can be trusted before its content is loaded and
+ * executed inside the Nextflow process.
+ *
+ * The plugin cache ({@code NXF_PLUGINS_DIR}, {@code $NXF_HOME/plugins}) is expected to be a
+ * per-user, private trust boundary. When it is shared and writable by other users, a lower-trust
+ * user can pre-populate an official pinned plugin coordinate (e.g. {@code nf-amazon-3.10.0}) with
+ * attacker-controlled code that Nextflow would then load from the cache without any origin check.
+ *
+ * A directory is considered untrusted when a principal other than the current user can write to
+ * it, that is:
+ *
+ * - it is owned by a user other than the one running Nextflow (compared by UID), unless the
+ * owner is {@code root} or listed in {@code NXF_PLUGINS_TRUSTED_OWNERS}; or
+ * - it is world-writable (the {@code others-write} bit is set).
+ *
+ *
+ * This accepts the mainstream deployment shapes - a private user cache (including the
+ * group-writable {@code rwxrwxr-x} created under the common umask 002 with a user-private group),
+ * an admin-managed read-only cache owned by {@code root} or a configured service account - while
+ * rejecting an attacker-owned directory or a world-writable cache. When running as {@code root}
+ * (UID 0) the owner is always trusted (the admin's responsibility) but a world-writable cache is
+ * still refused.
+ *
+ * Note: a self-owned cache that is group-writable to a shared primary group is treated as
+ * trusted; this residual risk is accepted because user-private-group is the modern default and
+ * flagging it would produce false positives on almost every run. World-writable is always refused.
+ *
+ * The behaviour is controlled by the {@code NXF_PLUGINS_STRICT_MODE} environment variable:
+ * {@code warn} (default) logs a warning and continues, {@code strict} aborts the run, and
+ * {@code off} disables the check. On filesystems that do not support POSIX attributes, or when the
+ * owner/current UID cannot be determined, the check is a no-op (fails open).
+ *
+ * @author Claude
+ */
+@Slf4j
+@CompileStatic
+class PluginSecurity {
+
+ static final String MODE_WARN = 'warn'
+ static final String MODE_STRICT = 'strict'
+ static final String MODE_OFF = 'off'
+
+ static final Set MODES = [MODE_WARN, MODE_STRICT, MODE_OFF] as Set
+
+ private static final long ROOT_UID = 0
+
+ // track dirs already reported so a multi-plugin run emits at most one warning per dir
+ private static final Set reported = ConcurrentHashMap.newKeySet()
+
+ // emit the invalid-mode warning at most once per run
+ private static final AtomicBoolean invalidModeWarned = new AtomicBoolean()
+
+ /**
+ * Resolve the strict mode from the {@code NXF_PLUGINS_STRICT_MODE} environment variable.
+ *
+ * The value is normalised (lower-cased and trimmed) and validated against the known modes.
+ * An unset or blank value falls back to the default silently, while an unrecognised value
+ * (e.g. a typo) falls back to the default and logs a warning (once) so it is not silently
+ * ignored.
+ *
+ * @return one of {@code warn} (default), {@code strict} or {@code off}
+ */
+ static String getMode() {
+ final raw = SysEnv.get('NXF_PLUGINS_STRICT_MODE')
+ final mode = raw?.toLowerCase()?.trim()
+ if( !mode )
+ return MODE_WARN
+ if( !MODES.contains(mode) ) {
+ if( invalidModeWarned.compareAndSet(false, true) )
+ log.warn "Invalid NXF_PLUGINS_STRICT_MODE value '${raw}' - expected one of ${MODES.join(', ')}; using default '${MODE_WARN}'"
+ return MODE_WARN
+ }
+ return mode
+ }
+
+ /**
+ * The set of owner identities trusted as plugin cache owners, in addition to the current user
+ * and root, parsed from the {@code NXF_PLUGINS_TRUSTED_OWNERS} environment variable. Each
+ * comma-separated entry is either a numeric UID or a user name.
+ *
+ * @return the trimmed, non-empty trusted-owner tokens (may be empty, never {@code null})
+ */
+ static Set getTrustedOwners() {
+ final list = SysEnv.get('NXF_PLUGINS_TRUSTED_OWNERS')
+ if( !list )
+ return Collections.emptySet()
+ return list.tokenize(',')*.trim().findAll { it } as Set
+ }
+
+ /**
+ * Determine whether a plugin directory should be considered untrusted.
+ *
+ * @param perms The POSIX permissions of the directory
+ * @param ownerUid The UID of the directory owner
+ * @param currentUid The UID of the user running Nextflow
+ * @param trustedUids Additional owner UIDs trusted beyond the current user and root
+ * @return {@code true} if the directory cannot be trusted
+ */
+ static boolean isUntrusted(Set perms, long ownerUid, long currentUid, Set trustedUids) {
+ final worldWritable = perms.contains(PosixFilePermission.OTHERS_WRITE)
+ // running as root: trust any owner (admin's responsibility) but still flag a world-writable cache
+ if( currentUid == ROOT_UID )
+ return worldWritable
+ final ownerTrusted = ownerUid == currentUid || ownerUid == ROOT_UID || trustedUids.contains(ownerUid)
+ if( !ownerTrusted )
+ return true
+ // owner is trusted; group-write is fine (user-private-group / umask 002), only world-write is a risk
+ return worldWritable
+ }
+
+ /**
+ * Verify that the given plugin directory can be trusted, using the mode resolved from the
+ * environment.
+ *
+ * @param dir The plugin directory (the plugins root or a {@code $id-$version} directory)
+ */
+ static void checkTrustedDir(Path dir) {
+ checkTrustedDir(dir, getMode())
+ }
+
+ /**
+ * Verify that the given plugin directory can be trusted.
+ *
+ * @param dir The plugin directory (the plugins root or a {@code $id-$version} directory)
+ * @param mode The strict mode: {@code warn}, {@code strict} or {@code off}
+ */
+ static void checkTrustedDir(Path dir, String mode) {
+ if( mode == MODE_OFF || dir == null )
+ return
+ try {
+ final view = Files.getFileAttributeView(dir, PosixFileAttributeView)
+ // non-POSIX filesystem (e.g. Windows, some object stores) - nothing to check
+ if( view == null )
+ return
+ final PosixFileAttributes attrs = view.readAttributes()
+ final perms = attrs.permissions()
+
+ // resolve owner and current UID; if either is unavailable, fail open to avoid false positives
+ final ownerUid = ownerUid(dir)
+ final currentUid = currentUid()
+ if( ownerUid == null || currentUid == null )
+ return
+
+ final trustedUids = resolveTrustedUids(attrs.owner().name, ownerUid)
+ if( isUntrusted(perms, ownerUid, currentUid, trustedUids) ) {
+ final owner = attrs.owner().name
+ final msg = "Plugin directory '${dir}' is not secure (owner=${owner}, mode=${PosixFilePermissions.toString(perms)}) " +
+ "- it may be modified by other users, which could allow untrusted plugin code to run in your Nextflow process"
+ if( mode == MODE_STRICT )
+ throw new AbortOperationException("${msg} -- refusing to load plugins. Use a private directory (chmod 700) owned by you, add the owner to NXF_PLUGINS_TRUSTED_OWNERS, or set NXF_PLUGINS_STRICT_MODE=warn to override")
+ // warn at most once per offending directory
+ if( reported.add(dir.toString()) )
+ log.warn "${msg} -- set NXF_PLUGINS_STRICT_MODE=strict to refuse loading, add the owner to NXF_PLUGINS_TRUSTED_OWNERS, or use a private plugins directory owned by you"
+ }
+ }
+ catch( AbortOperationException e ) {
+ throw e
+ }
+ catch( Exception e ) {
+ // never let a permission-inspection failure break a run
+ log.debug "Unable to verify plugin directory permissions for '${dir}' - ${e.message}"
+ }
+ }
+
+ /**
+ * Resolve the trusted owner UIDs from {@code NXF_PLUGINS_TRUSTED_OWNERS}. Numeric entries are
+ * matched by UID; a name entry matching the directory owner name resolves to the owner UID.
+ */
+ private static Set resolveTrustedUids(String ownerName, long ownerUid) {
+ final tokens = getTrustedOwners()
+ if( !tokens )
+ return Collections.emptySet()
+ final result = new HashSet()
+ for( String tok : tokens ) {
+ if( tok.isLong() )
+ result.add(tok.toLong())
+ else if( tok == ownerName )
+ result.add(ownerUid)
+ }
+ return result
+ }
+
+ /**
+ * @return the owner UID of the given path, or {@code null} if it cannot be determined
+ */
+ private static Long ownerUid(Path dir) {
+ try {
+ final uid = Files.getAttribute(dir, 'unix:uid')
+ return uid != null ? ((Number) uid).longValue() : null
+ }
+ catch( Exception e ) {
+ log.debug "Unable to read owner UID for '${dir}' - ${e.message}"
+ return null
+ }
+ }
+
+ /**
+ * @return the UID of the user running Nextflow, or {@code null} if it cannot be determined
+ */
+ private static Long currentUid() {
+ try {
+ return new com.sun.security.auth.module.UnixSystem().getUid()
+ }
+ catch( Throwable e ) {
+ log.debug "Unable to determine current user UID - ${e.message}"
+ return null
+ }
+ }
+}
diff --git a/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy b/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy
index e261563ed1..3a05cb12ed 100644
--- a/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy
+++ b/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy
@@ -401,6 +401,10 @@ class PluginUpdater extends UpdateManager {
log.warn("Plugin '${pluginPath.getFileName()}' installation looks corrupted - Delete the following directory and run nextflow again: $pluginPath")
}
+ // verify the plugin dir is a trusted (private, non-shared) location before loading it,
+ // to avoid executing attacker-controlled content from a shared/writable plugin cache
+ PluginSecurity.checkTrustedDir(pluginPath)
+
// load the plugin from the file system
PluginWrapper wrapper = pluginManager.loadPluginFromPath(pluginPath)
diff --git a/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy b/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy
index f5a99c7c59..af9dde2bc3 100644
--- a/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy
+++ b/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy
@@ -275,6 +275,9 @@ class PluginsFacade implements PluginStateListener {
// make sure plugins dir exists
if( mode!=DEV_MODE && !FilesEx.exists(root) && !FilesEx.mkdirs(root) )
throw new IOException("Unable to create plugins dir: $root")
+ // verify the plugins dir is a trusted (private, non-shared) location
+ if( mode!=DEV_MODE )
+ PluginSecurity.checkTrustedDir(root)
this.manager = createManager(root, embedded)
this.updater = createUpdater(root, manager)
@@ -295,6 +298,9 @@ class PluginsFacade implements PluginStateListener {
this.manager.addPluginStateListener(this)
// setup the updater
this.updater = createUpdater(root, manager)
+ // verify the plugins dir is a trusted (private, non-shared) location
+ if( mode!=DEV_MODE )
+ PluginSecurity.checkTrustedDir(root)
// load plugins
manager.loadPlugins()
if( embedded ) {
diff --git a/modules/nf-commons/src/test/nextflow/plugin/PluginSecurityTest.groovy b/modules/nf-commons/src/test/nextflow/plugin/PluginSecurityTest.groovy
new file mode 100644
index 0000000000..5b38a1a8a4
--- /dev/null
+++ b/modules/nf-commons/src/test/nextflow/plugin/PluginSecurityTest.groovy
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2013-2026, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.plugin
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.PosixFilePermissions
+
+import nextflow.SysEnv
+import nextflow.exception.AbortOperationException
+import spock.lang.IgnoreIf
+import spock.lang.Specification
+import spock.lang.Unroll
+/**
+ *
+ * @author Claude
+ */
+@IgnoreIf({ os.windows })
+class PluginSecurityTest extends Specification {
+
+ private Path tempDirWithPerms(String perms) {
+ final dir = Files.createTempDirectory('test-plugin-sec')
+ Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString(perms))
+ return dir
+ }
+
+ @Unroll
+ def 'should classify trust by uid and perms - owner=#OWNER current=#CUR perms=#PERMS trusted=#TRUSTED untrusted=#UNTRUSTED' () {
+ expect:
+ PluginSecurity.isUntrusted(PosixFilePermissions.fromString(PERMS), OWNER, CUR, TRUSTED) == UNTRUSTED
+
+ where:
+ OWNER | CUR | PERMS | TRUSTED | UNTRUSTED
+ // self-owned: private, read-only, or umask-002 group-writable -> trusted
+ 1000L | 1000L | 'rwx------' | ([] as Set) | false
+ 1000L | 1000L | 'rwxr-xr-x' | ([] as Set) | false
+ 1000L | 1000L | 'rwxrwxr-x' | ([] as Set) | false // umask 002 default - the key regression
+ 1000L | 1000L | 'rwxrwx---' | ([] as Set) | false
+ // self-owned but world-writable -> untrusted
+ 1000L | 1000L | 'rwxrwxrwx' | ([] as Set) | true
+ // root-owned admin cache -> trusted; root-owned world-writable -> untrusted
+ 0L | 1000L | 'rwxr-xr-x' | ([] as Set) | false
+ 0L | 1000L | 'rwxrwxrwx' | ([] as Set) | true
+ // foreign owner -> untrusted even with safe perms (the PoC case)
+ 1201L | 1202L | 'rwx------' | ([] as Set) | true
+ 1201L | 1202L | 'rwxr-xr-x' | ([] as Set) | true
+ // foreign owner listed as trusted (service-account cache) -> trusted
+ 1201L | 1202L | 'rwx------' | ([1201L] as Set) | false
+ // running as root: trust any owner, but still flag world-writable
+ 1201L | 0L | 'rwx------' | ([] as Set) | false
+ 1201L | 0L | 'rwxrwxrwx' | ([] as Set) | true
+ }
+
+ @Unroll
+ def 'should accept a self-owned dir that is not world-writable - mode=#MODE perms=#PERMS' () {
+ given:
+ def dir = tempDirWithPerms(PERMS)
+
+ when:
+ PluginSecurity.checkTrustedDir(dir, MODE)
+ then:
+ noExceptionThrown()
+
+ cleanup:
+ dir?.deleteDir()
+
+ where:
+ MODE | PERMS
+ 'strict' | 'rwx------'
+ 'warn' | 'rwx------'
+ 'strict' | 'rwxr-xr-x'
+ 'strict' | 'rwxrwxr-x' // umask-002 group-writable, owned by me
+ 'warn' | 'rwxrwx---'
+ }
+
+ @Unroll
+ def 'should reject a world-writable dir in strict mode - perms=#PERMS' () {
+ given:
+ def dir = tempDirWithPerms(PERMS)
+
+ when:
+ PluginSecurity.checkTrustedDir(dir, 'strict')
+ then:
+ def e = thrown(AbortOperationException)
+ e.message.contains('is not secure')
+
+ cleanup:
+ dir?.deleteDir()
+
+ where:
+ PERMS << ['rwxrwxrwx', 'rwx----w-']
+ }
+
+ def 'should only warn (not throw) for a world-writable dir in warn mode' () {
+ given:
+ def dir = tempDirWithPerms('rwxrwxrwx')
+
+ when:
+ PluginSecurity.checkTrustedDir(dir, 'warn')
+ then:
+ noExceptionThrown()
+
+ cleanup:
+ dir?.deleteDir()
+ }
+
+ def 'should skip the check entirely when mode is off' () {
+ given:
+ def dir = tempDirWithPerms('rwxrwxrwx')
+
+ when:
+ PluginSecurity.checkTrustedDir(dir, 'off')
+ then:
+ noExceptionThrown()
+
+ cleanup:
+ dir?.deleteDir()
+ }
+
+ def 'should be a no-op for a null dir' () {
+ when:
+ PluginSecurity.checkTrustedDir(null, 'strict')
+ then:
+ noExceptionThrown()
+ }
+
+ def 'should default to warn mode from the environment' () {
+ given:
+ SysEnv.push([:])
+ expect:
+ PluginSecurity.getMode() == 'warn'
+
+ cleanup:
+ SysEnv.pop()
+ }
+
+ @Unroll
+ def 'should resolve mode #VALUE from the environment' () {
+ given:
+ SysEnv.push([NXF_PLUGINS_STRICT_MODE: VALUE])
+ expect:
+ PluginSecurity.getMode() == VALUE
+
+ cleanup:
+ SysEnv.pop()
+
+ where:
+ VALUE << ['warn', 'strict', 'off']
+ }
+
+ @Unroll
+ def 'should normalise the env value #VALUE to #EXPECTED' () {
+ given:
+ SysEnv.push([NXF_PLUGINS_STRICT_MODE: VALUE])
+ expect:
+ PluginSecurity.getMode() == EXPECTED
+
+ cleanup:
+ SysEnv.pop()
+
+ where:
+ VALUE | EXPECTED
+ 'STRICT' | 'strict'
+ 'Warn' | 'warn'
+ ' off ' | 'off'
+ ' Strict ' | 'strict'
+ }
+
+ @Unroll
+ def 'should fall back to warn for an unrecognised or blank env value #VALUE' () {
+ given:
+ SysEnv.push([NXF_PLUGINS_STRICT_MODE: VALUE])
+ expect:
+ PluginSecurity.getMode() == 'warn'
+
+ cleanup:
+ SysEnv.pop()
+
+ where:
+ VALUE << ['bogus', 'strictly', '', ' ']
+ }
+
+ @Unroll
+ def 'should parse NXF_PLUGINS_TRUSTED_OWNERS #VALUE' () {
+ given:
+ SysEnv.push(VALUE != null ? [NXF_PLUGINS_TRUSTED_OWNERS: VALUE] : [:])
+ expect:
+ PluginSecurity.getTrustedOwners() == EXPECTED
+
+ cleanup:
+ SysEnv.pop()
+
+ where:
+ VALUE | EXPECTED
+ null | ([] as Set)
+ '' | ([] as Set)
+ '1000' | (['1000'] as Set)
+ 'nextflow' | (['nextflow'] as Set)
+ '1000, nextflow ,' | (['1000', 'nextflow'] as Set)
+ }
+
+}