Artem Chernyshev 78544a8557
feat: restrict directories for included files in the cluster templates
By default only allow to include files from the same directory where the
template file lives.
This is to prevent malicious cluster templates that include something
like `/etc/passwd`.
Fixes: https://github.com/siderolabs/omni/issues/2590

Signed-off-by: Artem Chernyshev <artem.chernyshev@talos-systems.com>
2026-04-16 19:28:33 +03:00

185 lines
4.9 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Package models provides cluster template models (for each sub-document of multi-doc YAML).
package models
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/resource/kvutils"
"github.com/hashicorp/go-multierror"
"github.com/siderolabs/gen/pair"
"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
)
// Meta is embedded into all template objects.
type Meta struct {
Kind string `yaml:"kind"`
}
// TranslateContext is a context for translation.
type TranslateContext struct {
// Root restricts file access to a single directory tree. When nil, no restriction is applied.
Root *os.Root
LockedMachines map[MachineID]struct{}
MachineDescriptors map[MachineID]Descriptors
MachineSetLevelKernelArgs map[MachineID]KernelArgs
ClusterLevelKernelArgs KernelArgs
ClusterName string
}
// SystemExtensions is embedded in Cluster, MachineSet and Machine objects.
type SystemExtensions struct {
SystemExtensions []string `yaml:"systemExtensions,omitempty"`
}
func (s *SystemExtensions) translate(ctx TranslateContext, nameSuffix string, labels ...pair.Pair[string, string]) []resource.Resource {
if len(s.SystemExtensions) == 0 {
return nil
}
configuration := omni.NewExtensionsConfiguration(fmt.Sprintf("schematic-%s", nameSuffix))
configuration.Metadata().Labels().Set(omni.LabelCluster, ctx.ClusterName)
configuration.Metadata().Labels().Do(func(temp kvutils.TempKV) {
for _, l := range labels {
temp.Set(l.F1, l.F2)
}
})
configuration.TypedSpec().Value.Extensions = s.SystemExtensions
return []resource.Resource{
configuration,
}
}
// Descriptors are the user descriptors (i.e. Labels, Annotations) to apply to the resource.
type Descriptors struct {
// Labels are the user labels to apply to the resource.
Labels map[string]string `yaml:"labels,omitempty"`
// Annotations are the user annotations to apply to the resource.
Annotations map[string]string `yaml:"annotations,omitempty"`
}
// Validate validates the descriptors.
func (d *Descriptors) Validate() error {
var multiErr error
for labelKey := range d.Labels {
if strings.HasPrefix(labelKey, omni.SystemLabelPrefix) {
multiErr = multierror.Append(multiErr, fmt.Errorf("label %q is invalid: prefix %q is reserved for internal use", labelKey, omni.SystemLabelPrefix))
}
}
for annotationKey := range d.Annotations {
if strings.HasPrefix(annotationKey, omni.SystemLabelPrefix) {
multiErr = multierror.Append(multiErr, fmt.Errorf("annotation %q is invalid: prefix %q is reserved for internal use", annotationKey, omni.SystemLabelPrefix))
}
}
return multiErr
}
// Apply applies the descriptors to the given resource.
func (d *Descriptors) Apply(res resource.Resource) {
for k, v := range d.Labels {
res.Metadata().Labels().Set(k, v)
}
for k, v := range d.Annotations {
res.Metadata().Annotations().Set(k, v)
}
}
// ValidateOptions contains options for model validation.
type ValidateOptions struct {
// Root restricts file access to a single directory tree. When nil, no restriction is applied.
Root *os.Root
}
// resolveForRoot translates a CWD-relative path into a path relative to the root directory.
func resolveForRoot(root *os.Root, path string) (string, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
rootAbs, err := filepath.Abs(root.Name())
if err != nil {
return "", err
}
return filepath.Rel(rootAbs, absPath)
}
// ReadFile reads a file, using root to restrict access when non-nil.
func ReadFile(root *os.Root, path string) ([]byte, error) {
if root == nil {
return os.ReadFile(path)
}
rel, err := resolveForRoot(root, path)
if err != nil {
return nil, err
}
return root.ReadFile(rel)
}
// StatFile stats a file, using root to restrict access when non-nil.
func StatFile(root *os.Root, path string) (os.FileInfo, error) {
if root == nil {
return os.Stat(path)
}
rel, err := resolveForRoot(root, path)
if err != nil {
return nil, err
}
return root.Stat(rel)
}
// Model is a base interface for cluster templates.
type Model interface {
Validate(ValidateOptions) error
Translate(TranslateContext) ([]resource.Resource, error)
}
var registeredModels = map[string]func() Model{}
type model[T any] interface {
*T
Model
}
func register[T any, P model[T]](kind string) {
if _, ok := registeredModels[kind]; ok {
panic(fmt.Sprintf("model %s already registered", kind))
}
registeredModels[kind] = func() Model {
return P(new(T))
}
}
// New creates a model by kind.
func New(kind string) (Model, error) {
f, ok := registeredModels[kind]
if !ok {
return nil, fmt.Errorf("unknown model kind %q", kind)
}
return f(), nil
}