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) + } + +}