--- .changelog/27919.txt @@ -0,0 +1,3 @@ +```release-note:security +dynamic host volumes: Prevent unintended code execution outside the plugin directory (CVE-2026-7474) +``` --- client/hostvolumemanager/host_volume_plugin.go @@ -223,20 +223,27 @@ var _ HostVolumePlugin = &HostVolumePluginExternal{} // if the specified executable exists on disk. func NewHostVolumePluginExternal(log hclog.Logger, pluginDir, filename, volumesDir, nodePool string) (*HostVolumePluginExternal, error) { - // this should only be called with already-detected executables, - // but we'll double-check it anyway, so we can provide a tidy error message - // if it has changed between fingerprinting and execution. - executable := filepath.Join(pluginDir, filename) - f, err := os.Stat(executable) + // this should only be called with already-detected executables, but we'll + // double-check it anyway, so we can provide a tidy error message if it has + // changed between fingerprinting and execution and to ensure that the + // plugin ID can't traverse outside the plugin directory. + root, err := os.OpenRoot(pluginDir) if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename) - } - return nil, err + return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename) + } + + f, err := root.Stat(filename) + if err != nil { + // note we intentionally obscure the root-escape error here and return a + // ErrPluginNotExists, because there's no legitimate reason to ever get + // this error + return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename) } if !helper.IsExecutable(f) { return nil, fmt.Errorf("%w: %q", ErrPluginNotExecutable, filename) } + executable := filepath.Join(pluginDir, filename) + return &HostVolumePluginExternal{ ID: filename, Executable: executable, --- client/hostvolumemanager/host_volume_plugin_test.go @@ -218,6 +218,14 @@ func TestNewHostVolumePluginExternal(t *testing.T) { _, err = NewHostVolumePluginExternal(log, ".", "non-existent", "target", "") must.ErrorIs(t, err, ErrPluginNotExists) + // OpenRoot fails + _, err = NewHostVolumePluginExternal(log, "/xxxxxxxxxxxxxxxxx", "non-existent", "target", "") + must.ErrorIs(t, err, ErrPluginNotExists) + + // tries to escape root + _, err = NewHostVolumePluginExternal(log, ".", "../non-existent", "target", "") + must.ErrorIs(t, err, ErrPluginNotExists) + _, err = NewHostVolumePluginExternal(log, ".", "host_volume_plugin_test.go", "target", "") must.ErrorIs(t, err, ErrPluginNotExecutable) --- client/hostvolumemanager/host_volumes_test.go @@ -363,7 +363,7 @@ func TestHostVolumeManager_restoreFromState(t *testing.T) { } must.NoError(t, state.PutDynamicHostVolume(vol)) - hvm := NewHostVolumeManager(log, Config{StateMgr: state}) + hvm := NewHostVolumeManager(log, Config{StateMgr: state, PluginDir: t.TempDir()}) vols, err := hvm.restoreFromState(timeout(t)) must.ErrorIs(t, err, ErrPluginNotExists) must.MapEmpty(t, vols) --- nomad/host_volume_endpoint.go @@ -521,6 +521,18 @@ func (v *HostVolume) registerVolume(vol *structs.HostVolume) error { // by that name. It returns the node (for testing) and an error indicating // placement failed. func (v *HostVolume) placeHostVolume(snap *state.StateSnapshot, vol *structs.HostVolume) (*structs.Node, error) { + ctx := &placementContext{ + regexpCache: make(map[string]*regexp.Regexp), + versionCache: make(map[string]feasible.VerConstraints), + semverCache: make(map[string]feasible.VerConstraints), + } + constraints := []*structs.Constraint{{ + LTarget: fmt.Sprintf("${attr.plugins.host_volume.%s.version}", vol.PluginID), + Operand: "is_set", + }} + constraints = append(constraints, vol.Constraints...) + checker := feasible.NewConstraintChecker(ctx, constraints) + if vol.NodeID != "" { node, err := snap.NodeByID(nil, vol.NodeID) if err != nil { @@ -529,6 +541,10 @@ func (v *HostVolume) placeHostVolume(snap *state.StateSnapshot, vol *structs.Hos if node == nil { return nil, fmt.Errorf("no such node %s", vol.NodeID) } + if ok := checker.Feasible(node); !ok { + return nil, fmt.Errorf("node %s is not feasible for volume", vol.NodeID) + } + vol.NodePool = node.NodePool return node, nil } @@ -552,19 +568,6 @@ func (v *HostVolume) placeHostVolume(snap *state.StateSnapshot, vol *structs.Hos return nil, err } - var checker *feasible.ConstraintChecker - ctx := &placementContext{ - regexpCache: make(map[string]*regexp.Regexp), - versionCache: make(map[string]feasible.VerConstraints), - semverCache: make(map[string]feasible.VerConstraints), - } - constraints := []*structs.Constraint{{ - LTarget: fmt.Sprintf("${attr.plugins.host_volume.%s.version}", vol.PluginID), - Operand: "is_set", - }} - constraints = append(constraints, vol.Constraints...) - checker = feasible.NewConstraintChecker(ctx, constraints) - var ( filteredByExisting int filteredByGovernance int