incus-mirror/cmd/incusd/daemon_images.go
Stéphane Graber 42be307a14
incusd/images: Validate image fingerprint for all protocols
The fingerprint validation added for CVE-2026-48769 was only applied to
the direct protocol. Validate it for all protocols and re-check after it
is taken from the remote server's response.

This addresses GHSA-p2v3-6wvc-cv3p (CVE pending)

Signed-off-by: Stéphane Graber <stgraber@stgraber.org>
2026-07-30 09:45:37 -04:00

683 lines
20 KiB
Go

package main
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"time"
incus "github.com/lxc/incus/v7/client"
internalIO "github.com/lxc/incus/v7/internal/io"
"github.com/lxc/incus/v7/internal/server/db"
"github.com/lxc/incus/v7/internal/server/db/cluster"
"github.com/lxc/incus/v7/internal/server/locking"
"github.com/lxc/incus/v7/internal/server/operations"
"github.com/lxc/incus/v7/internal/server/project"
"github.com/lxc/incus/v7/internal/server/response"
"github.com/lxc/incus/v7/internal/server/state"
localUtil "github.com/lxc/incus/v7/internal/server/util"
internalUtil "github.com/lxc/incus/v7/internal/util"
"github.com/lxc/incus/v7/internal/version"
"github.com/lxc/incus/v7/shared/api"
"github.com/lxc/incus/v7/shared/cancel"
"github.com/lxc/incus/v7/shared/ioprogress"
"github.com/lxc/incus/v7/shared/logger"
"github.com/lxc/incus/v7/shared/units"
"github.com/lxc/incus/v7/shared/util"
"github.com/lxc/incus/v7/shared/validate"
)
// imageDownloadArgs used with imageDownload.
type imageDownloadArgs struct {
ProjectName string
Server string
Protocol string
Certificate string
Secret string
Alias string
Type string
SetCached bool
PreferCached bool
AutoUpdate bool
Public bool
StoragePool string
Budget int64
SourceProjectName string
}
// imageOperationLock acquires a lock for operating on an image and returns the unlock function.
func imageOperationLock(ctx context.Context, fingerprint string) (locking.UnlockFunc, error) {
l := logger.AddContext(logger.Ctx{"fingerprint": fingerprint})
l.Debug("Acquiring lock for image")
defer l.Debug("Lock acquired for image")
return locking.Lock(ctx, fmt.Sprintf("ImageOperation_%s", fingerprint))
}
// imageDownload resolves the image fingerprint and if not in the database, downloads it.
func imageDownload(ctx context.Context, r *http.Request, s *state.State, op *operations.Operation, args *imageDownloadArgs) (*api.Image, bool, error) {
var err error
var ctxMap logger.Ctx
var remote incus.ImageServer
var info *api.Image
// Check if the project allows retrieving the image.
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
err := project.AllowImageDownload(tx, args.ProjectName, args.Server)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, false, err
}
// Default protocol is Incus. Copy so that local modifications aren't propagated to args.
protocol := args.Protocol
if protocol == "" {
protocol = "incus"
}
// Copy so that local modifications aren't propagated to args.
alias := args.Alias
// Default the fingerprint to the alias string we received
fp := alias
// Attempt to resolve the alias
if args.Server != "" && slices.Contains([]string{"incus", "lxd", "oci", "simplestreams"}, protocol) {
clientArgs := &incus.ConnectionArgs{
TLSServerCert: args.Certificate,
UserAgent: version.UserAgent,
Proxy: s.Proxy,
CachePath: s.OS.CacheDir,
CacheExpiry: time.Hour,
SkipGetEvents: true,
SkipGetServer: true,
TempPath: internalUtil.VarPath("images"),
}
if slices.Contains([]string{"incus", "lxd"}, protocol) {
// Setup client
remote, err = incus.ConnectPublicIncus(args.Server, clientArgs)
if err != nil {
return nil, false, fmt.Errorf("Failed to connect to the server %q: %w", args.Server, err)
}
server, ok := remote.(incus.InstanceServer)
if ok {
remote = server.UseProject(args.SourceProjectName)
}
} else if protocol == "oci" {
// Setup OCI client
remote, err = incus.ConnectOCI(args.Server, clientArgs)
if err != nil {
return nil, false, fmt.Errorf("Failed to connect to oci server %q: %w", args.Server, err)
}
} else if protocol == "simplestreams" {
// Setup simplestreams client
remote, err = incus.ConnectSimpleStreams(args.Server, clientArgs)
if err != nil {
return nil, false, fmt.Errorf("Failed to connect to simple streams server %q: %w", args.Server, err)
}
}
// For public images, handle aliases and initial metadata
if args.Secret == "" {
// Look for a matching alias. Note, this err message is lost!
entry, _, err := remote.GetImageAliasType(args.Type, fp)
if err == nil {
fp = entry.Target
} else if args.Type != "" {
// If no match was found for the requested type, check whether
// the other instance type has one to give a more helpful hint.
otherType := "virtual-machine"
if args.Type == "virtual-machine" {
otherType = "container"
}
_, _, otherErr := remote.GetImageAliasType(otherType, fp)
if otherErr == nil {
return nil, false, fmt.Errorf("The requested image couldn't be found for instance type %q, but one was found for instance type %q", args.Type, otherType)
}
}
// Expand partial fingerprints
info, _, err = remote.GetImage(fp)
if err != nil {
return nil, false, fmt.Errorf("Failed getting remote image info: %w", err)
}
fp = info.Fingerprint
}
}
// Ensure we are the only ones operating on this image.
unlock, err := imageOperationLock(ctx, fp)
if err != nil {
return nil, false, err
}
defer unlock()
// If auto-update is on and we're being given the image by
// alias, try to use a locally cached image matching the given
// server/protocol/alias, regardless of whether it's stale or
// not (we can assume that it will be not *too* stale since
// auto-update is on).
interval := s.GlobalConfig.ImagesAutoUpdateIntervalHours()
if args.PreferCached && interval > 0 && alias != fp {
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
for _, architecture := range s.OS.Architectures {
cachedFingerprint, err := tx.GetCachedImageSourceFingerprint(ctx, args.Server, args.Protocol, alias, args.Type, architecture)
if err == nil && cachedFingerprint != fp {
fp = cachedFingerprint
break
}
}
return nil
})
if err != nil {
return nil, false, err
}
}
var imgInfo *api.Image
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// Check if the image already exists in this project (partial hash match).
_, imgInfo, err = tx.GetImage(ctx, fp, cluster.ImageFilter{Project: &args.ProjectName})
return err
})
if err == nil {
var nodeAddress string
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// Check if the image is available locally or it's on another node.
nodeAddress, err = tx.LocateImage(ctx, imgInfo.Fingerprint)
return err
})
if err != nil {
return nil, false, fmt.Errorf("Failed locating image %q in the cluster: %w", imgInfo.Fingerprint, err)
}
if nodeAddress != "" {
// The image is available from another node, let's try to import it.
err = instanceImageTransfer(s, r, args.ProjectName, imgInfo.Fingerprint, nodeAddress)
if err != nil {
return nil, false, fmt.Errorf("Failed transferring image %q from %q: %w", imgInfo.Fingerprint, nodeAddress, err)
}
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// As the image record already exists in the project, just add the node ID to the image.
return tx.AddImageToLocalNode(ctx, args.ProjectName, imgInfo.Fingerprint)
})
if err != nil {
return nil, false, fmt.Errorf("Failed adding transferred image %q to local cluster member: %w", imgInfo.Fingerprint, err)
}
}
} else if response.IsNotFoundError(err) {
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// Check if the image already exists in some other project.
_, imgInfo, err = tx.GetImageFromAnyProject(ctx, fp)
return err
})
if err == nil {
var nodeAddress string
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// Check if the image is available locally or it's on another node. Do this before creating
// the missing DB record so we don't include ourself in the search results.
nodeAddress, err = tx.LocateImage(ctx, imgInfo.Fingerprint)
if err != nil {
return fmt.Errorf("Locate image %q in the cluster: %w", imgInfo.Fingerprint, err)
}
// We need to insert the database entry for this project, including the node ID entry.
err = tx.CreateImage(ctx, args.ProjectName, imgInfo.Fingerprint, imgInfo.Filename, imgInfo.Size, args.Public, imgInfo.AutoUpdate, imgInfo.Architecture, imgInfo.CreatedAt, imgInfo.ExpiresAt, imgInfo.Properties, imgInfo.Type, nil)
if err != nil {
return fmt.Errorf("Failed creating image record for project: %w", err)
}
// Mark the image as "cached" if downloading for an instance.
if args.SetCached {
err = tx.SetImageCachedAndLastUseDate(ctx, args.ProjectName, imgInfo.Fingerprint, time.Now().UTC())
if err != nil {
return fmt.Errorf("Failed setting cached flag and last use date: %w", err)
}
}
var id int
id, imgInfo, err = tx.GetImage(ctx, fp, cluster.ImageFilter{Project: &args.ProjectName})
if err != nil {
return err
}
return tx.CreateImageSource(ctx, id, args.Server, args.Protocol, args.Certificate, alias)
})
if err != nil {
return nil, false, err
}
// Transfer image if needed (after database record has been created above).
if nodeAddress != "" {
// The image is available from another node, let's try to import it.
err = instanceImageTransfer(s, r, args.ProjectName, info.Fingerprint, nodeAddress)
if err != nil {
return nil, false, fmt.Errorf("Failed transferring image: %w", err)
}
}
}
}
if imgInfo != nil {
info = imgInfo
ctxMap = logger.Ctx{"fingerprint": info.Fingerprint}
logger.Debug("Image already exists in the DB", ctxMap)
// If not requested in a particular pool, we're done.
if args.StoragePool == "" {
return info, false, nil
}
ctxMap["pool"] = args.StoragePool
var poolID int64
var poolIDs []int64
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// Get the ID of the target storage pool.
poolID, err = tx.GetStoragePoolID(ctx, args.StoragePool)
if err != nil {
return err
}
// Check if the image is already in the pool.
poolIDs, err = tx.GetPoolsWithImage(ctx, info.Fingerprint)
return err
})
if err != nil {
return nil, false, err
}
if slices.Contains(poolIDs, poolID) {
logger.Debug("Image already exists on storage pool", ctxMap)
return info, false, nil
}
// Import the image in the pool.
logger.Debug("Image does not exist on storage pool", ctxMap)
err = imageCreateInPool(s, info, args.StoragePool)
if err != nil {
ctxMap["err"] = err
logger.Debug("Failed to create image on storage pool", ctxMap)
return nil, false, fmt.Errorf("Failed to create image %q on storage pool %q: %w", info.Fingerprint, args.StoragePool, err)
}
logger.Debug("Created image on storage pool", ctxMap)
return info, false, nil
}
// Begin downloading
if op == nil {
ctxMap = logger.Ctx{"alias": alias, "server": args.Server}
} else {
ctxMap = logger.Ctx{"trigger": op.URL(), "fingerprint": fp, "operation": op.ID(), "alias": alias, "server": args.Server}
}
logger.Info("Downloading image", ctxMap)
// The fingerprint is used as a file name, so reject anything that isn't a
// partial or full hex fingerprint to avoid path traversal. It's re-validated
// as a full SHA-256 once expanded from the remote below.
match, _ := regexp.MatchString("^[0-9a-f]{1,64}$", fp)
if !match {
return nil, false, errors.New("Invalid image fingerprint")
}
// Cleanup any leftover from a past attempt
destDir := internalUtil.VarPath("images")
destName := filepath.Join(destDir, fp)
failure := true
cleanup := func() {
if failure {
_ = os.Remove(destName)
_ = os.Remove(destName + ".rootfs")
}
}
defer cleanup()
// Setup a progress handler
progress := func(progress ioprogress.ProgressData) {
if op == nil {
return
}
meta := op.Metadata()
if meta == nil {
meta = make(map[string]any)
}
if meta["download_progress"] != progress.Text {
_ = op.ExtendMetadata(map[string]any{"download_progress": progress.Text})
}
}
var canceler *cancel.HTTPRequestCanceller
if op != nil {
canceler = cancel.NewHTTPRequestCanceller()
op.SetCanceler(canceler)
}
if slices.Contains([]string{"incus", "lxd", "oci", "simplestreams"}, protocol) {
// Create the target files
dest, err := os.Create(destName)
if err != nil {
return nil, false, err
}
defer logger.WarnOnErrorExcept(dest.Close, []error{os.ErrClosed}, "Failed to close image file")
destRootfs, err := os.Create(destName + ".rootfs")
if err != nil {
return nil, false, err
}
defer logger.WarnOnErrorExcept(destRootfs.Close, []error{os.ErrClosed}, "Failed to close rootfs file")
// Get the image information
if info == nil {
if args.Secret != "" {
info, _, err = remote.GetPrivateImage(fp, args.Secret)
if err != nil {
return nil, false, err
}
// Expand the fingerprint now and mark alias string to match
fp = info.Fingerprint
alias = info.Fingerprint
} else {
info, _, err = remote.GetImage(fp)
if err != nil {
return nil, false, err
}
}
}
// Compatibility with older servers
if info.Type == "" {
info.Type = "container"
}
if args.Budget > 0 && info.Size > args.Budget {
return nil, false, fmt.Errorf("Remote image with size %d exceeds allowed bugdget of %d", info.Size, args.Budget)
}
// Download the image
var resp *incus.ImageFileResponse
request := incus.ImageFileRequest{
MetaFile: io.ReadWriteSeeker(dest),
RootfsFile: io.ReadWriteSeeker(destRootfs),
ProgressHandler: progress,
Canceler: canceler,
DeltaSourceRetriever: func(fingerprint string, file string) string {
path := internalUtil.VarPath("images", fmt.Sprintf("%s.%s", fingerprint, file))
if util.PathExists(path) {
return path
}
return ""
},
}
if args.Secret != "" {
resp, err = remote.GetPrivateImageFile(fp, args.Secret, request)
} else {
resp, err = remote.GetImageFile(fp, request)
}
if err != nil {
return nil, false, err
}
// Truncate down to size
if resp.RootfsSize > 0 {
err = destRootfs.Truncate(resp.RootfsSize)
if err != nil {
return nil, false, err
}
}
err = dest.Truncate(resp.MetaSize)
if err != nil {
return nil, false, err
}
// Deal with unified images
if resp.RootfsSize == 0 {
err := os.Remove(destName + ".rootfs")
if err != nil {
return nil, false, err
}
}
err = dest.Close()
if err != nil {
return nil, false, err
}
err = destRootfs.Close()
if err != nil {
return nil, false, err
}
} else if protocol == "direct" {
// Setup HTTP client
httpClient, err := localUtil.HTTPClient(args.Certificate, s.Proxy)
if err != nil {
return nil, false, err
}
// Use relatively short response header timeout so as not to hold the image lock open too long.
httpTransport, ok := httpClient.Transport.(*http.Transport)
if !ok {
return nil, false, errors.New("Unexpected HTTP transport type")
}
httpTransport.ResponseHeaderTimeout = 30 * time.Second
req, err := http.NewRequest("GET", args.Server, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("User-Agent", version.UserAgent)
// Make the request
raw, doneCh, err := cancel.CancelableDownload(canceler, httpClient.Do, req)
if err != nil {
return nil, false, err
}
defer close(doneCh)
if raw.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("Unable to fetch %q: %s", args.Server, raw.Status)
}
// Progress handler
body := &ioprogress.ProgressReader{
ReadCloser: raw.Body,
Tracker: &ioprogress.ProgressTracker{
Length: raw.ContentLength,
Handler: func(percent int64, speed int64) {
progress(ioprogress.ProgressData{Text: fmt.Sprintf("%d%% (%s/s)", percent, units.GetByteSizeString(speed, 2))})
},
},
}
// Create the target files
f, err := os.Create(destName)
if err != nil {
return nil, false, err
}
defer logger.WarnOnErrorExcept(f.Close, []error{os.ErrClosed}, "Failed to close image file")
// Hashing
hash256 := sha256.New()
// Download the image
writer := internalIO.NewQuotaWriter(io.MultiWriter(f, hash256), args.Budget)
size, err := util.SafeCopy(writer, body)
if err != nil {
return nil, false, err
}
// Validate hash
result := fmt.Sprintf("%x", hash256.Sum(nil))
if result != fp {
return nil, false, fmt.Errorf("Hash mismatch for %q: %s != %s", args.Server, result, fp)
}
// Parse the image
imageMeta, imageType, err := getImageMetadata(destName)
if err != nil {
return nil, false, err
}
info = &api.Image{}
info.Fingerprint = fp
info.Size = size
info.Architecture = imageMeta.Architecture
info.Properties = imageMeta.Properties
info.Type = imageType
if imageMeta.CreationDate > 0 {
info.CreatedAt = time.Unix(imageMeta.CreationDate, 0)
}
if imageMeta.ExpiryDate > 0 {
info.ExpiresAt = time.Unix(imageMeta.ExpiryDate, 0)
}
err = f.Close()
if err != nil {
return nil, false, err
}
} else {
return nil, false, fmt.Errorf("Unsupported protocol: %v", protocol)
}
// Override visibility
info.Public = args.Public
// We want to enable auto-update only if we were passed an
// alias name, so we can figure when the associated
// fingerprint changes in the remote.
if alias != fp {
info.AutoUpdate = args.AutoUpdate
}
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
// Create the database entry
return tx.CreateImage(ctx, args.ProjectName, info.Fingerprint, info.Filename, info.Size, info.Public, info.AutoUpdate, info.Architecture, info.CreatedAt, info.ExpiresAt, info.Properties, info.Type, nil)
})
if err != nil && api.StatusErrorCheck(err, http.StatusConflict) {
// Another cluster member created the record concurrently, reuse it and just register this member.
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
_, info, err = tx.GetImage(ctx, info.Fingerprint, cluster.ImageFilter{Project: &args.ProjectName})
if err != nil {
return err
}
return tx.AddImageToLocalNode(ctx, args.ProjectName, info.Fingerprint)
})
}
if err != nil {
return nil, false, fmt.Errorf("Failed creating image record: %w", err)
}
// Image is in the DB now, don't wipe on-disk files on failure
failure = false
// Re-validate the fingerprint as it may have been updated from the remote
// server's response, and it is used as a file name below.
err = validate.IsSHA256(fp)
if err != nil {
return nil, false, errors.New("Invalid image fingerprint")
}
// Check if the image path changed (private images)
newDestName := filepath.Join(destDir, fp)
if newDestName != destName {
err = internalUtil.FileMove(destName, newDestName)
if err != nil {
return nil, false, err
}
if util.PathExists(destName + ".rootfs") {
err = internalUtil.FileMove(destName+".rootfs", newDestName+".rootfs")
if err != nil {
return nil, false, err
}
}
}
// Record the image source
if alias != fp {
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
id, _, err := tx.GetImage(ctx, fp, cluster.ImageFilter{Project: &args.ProjectName})
if err != nil {
return err
}
return tx.CreateImageSource(ctx, id, args.Server, protocol, args.Certificate, alias)
})
if err != nil {
return nil, false, err
}
}
// Import into the requested storage pool
if args.StoragePool != "" {
err = imageCreateInPool(s, info, args.StoragePool)
if err != nil {
return nil, false, err
}
}
// Mark the image as "cached" if downloading for an instance
if args.SetCached {
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
return tx.SetImageCachedAndLastUseDate(ctx, args.ProjectName, fp, time.Now().UTC())
})
if err != nil {
return nil, false, fmt.Errorf("Failed setting cached flag and last use date: %w", err)
}
}
logger.Info("Image downloaded", ctxMap)
return info, true, nil
}