124 lines
4.9 KiB
Diff
124 lines
4.9 KiB
Diff
--- .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
|