Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/plugins/using-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 is not writable by other users. Nextflow loads and executes cached plugin code without re-verifying its origin, so a shared plugin cache that is writable by other users (or owned by another user) allows a lower-trust user to replace cached plugin code with their own, which would then run inside your Nextflow process.

Keep `NXF_PLUGINS_DIR` private (for example `chmod 700`) and owned by you. If a shared cache is required, use an administrator-owned, **read-only** directory that regular users cannot modify. Use [`NXF_PLUGINS_STRICT_MODE`](../reference/env-vars.mdx) to control whether Nextflow warns about or refuses to load plugins from an untrusted directory.
:::
Comment thread
aknownuser marked this conversation as resolved.

## 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.
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/env-vars.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ 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`

<AddedInVersion version="26.07" />

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 another user or writable by group or others. 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.

##### `NXF_PLUGINS_TEST_REPOSITORY`

<AddedInVersion version="23.04" />
Expand Down
116 changes: 116 additions & 0 deletions modules/nf-commons/src/main/nextflow/plugin/PluginSecurity.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* 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 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 trusted only when it is owned by the current user (or root) AND it is
* not writable by group or others. This accepts the two legitimate deployment shapes - a private
* user cache (e.g. {@code 0700}) and an admin-managed read-only shared cache (e.g. {@code root:root
* 0755}) - while rejecting an attacker-owned directory or a group/world-writable cache.
*
* 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 the check is
* a no-op.
*
* @author Claude <noreply@anthropic.com>
*/
@Slf4j
@CompileStatic
class PluginSecurity {

static final String MODE_WARN = 'warn'
static final String MODE_STRICT = 'strict'
static final String MODE_OFF = 'off'

/**
* Resolve the strict mode from the {@code NXF_PLUGINS_STRICT_MODE} environment variable.
*
* @return one of {@code warn} (default), {@code strict} or {@code off}
*/
static String getMode() {
return SysEnv.get('NXF_PLUGINS_STRICT_MODE', MODE_WARN)
Comment thread
aknownuser marked this conversation as resolved.
Outdated
}

/**
* 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()
final owner = attrs.owner().name
final me = System.getProperty('user.name')

final writableByOthers = perms.contains(PosixFilePermission.GROUP_WRITE) || perms.contains(PosixFilePermission.OTHERS_WRITE)
final untrustedOwner = owner != me && owner != 'root'

if( writableByOthers || untrustedOwner ) {
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, or set NXF_PLUGINS_STRICT_MODE=warn to override")
log.warn "${msg} -- set NXF_PLUGINS_STRICT_MODE=strict to refuse loading, 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}"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ) {
Expand Down
141 changes: 141 additions & 0 deletions modules/nf-commons/src/test/nextflow/plugin/PluginSecurityTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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 <noreply@anthropic.com>
*/
@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 accept a private dir owned by current user - mode=#MODE perms=#PERMS' () {
Comment thread
aknownuser marked this conversation as resolved.
Outdated
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'
'warn' | 'rwxr-xr-x'
}

@Unroll
def 'should reject a group/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 << ['rwxrwx---', 'rwxrwxrwx', 'rwx-w----', 'rwx----w-']
}

@Unroll
def 'should only warn (not throw) for a group/world-writable dir in warn mode - perms=#PERMS' () {
given:
def dir = tempDirWithPerms(PERMS)

when:
PluginSecurity.checkTrustedDir(dir, 'warn')
then:
noExceptionThrown()

cleanup:
dir?.deleteDir()

where:
PERMS << ['rwxrwx---', 'rwxrwxrwx']
}

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']
}

}
Loading