spiegel_podman/pkg/api/handlers/libpod/quadlets.go
Madhosh Yagnik 94b0c1df14 pkg/api/handlers/libpod: close quadlet files per iteration and prevent duplicates
processMultipartQuadlets previously deferred closing each multipart part
and file inside the loop, leaking descriptors until the handler returned.
It also opened files with os.Create, silently overwriting earlier files if
a request contained parts with duplicate names.

Align processMultipartQuadlets with the manifests multipart upload handler:
- Wrap each part processing in a closure so part and file descriptors close
  immediately on each loop iteration.
- Use os.OpenFile with os.O_CREATE|os.O_EXCL|os.O_WRONLY (0600) so duplicate
  filenames return an error instead of silently overwriting.
- Sanitize filenames using filepath.Base to prevent directory traversal.
- Add unit tests verifying multiple file extraction, duplicate name rejection,
  and path traversal sanitization.

Fixes: #29752
Signed-off-by: Madhosh Yagnik <madhosh1yagnik@gmail.com>
2026-09-14 23:55:57 +05:30

396 lines
12 KiB
Go

//go:build !remote && (linux || freebsd)
package libpod
import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gorilla/schema"
"github.com/sirupsen/logrus"
"go.podman.io/podman/v6/libpod"
"go.podman.io/podman/v6/libpod/define"
"go.podman.io/podman/v6/pkg/api/handlers/utils"
api "go.podman.io/podman/v6/pkg/api/types"
"go.podman.io/podman/v6/pkg/domain/entities"
"go.podman.io/podman/v6/pkg/domain/infra/abi"
"go.podman.io/podman/v6/pkg/systemd/quadlet"
"go.podman.io/podman/v6/pkg/util"
"go.podman.io/storage/pkg/chrootarchive"
)
func ListQuadlets(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
filterMap, err := util.FiltersFromRequest(r)
if err != nil {
utils.Error(
w, http.StatusInternalServerError,
fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err),
)
return
}
containerEngine := abi.ContainerEngine{Libpod: runtime}
quadlets, err := containerEngine.QuadletList(r.Context(), entities.QuadletListOptions{Filters: filterMap})
if err != nil {
utils.InternalServerError(w, err)
return
}
utils.WriteResponse(w, http.StatusOK, quadlets)
}
func GetQuadletPrint(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
name := utils.GetName(r)
containerEngine := abi.ContainerEngine{Libpod: runtime}
quadletContents, err := containerEngine.QuadletPrint(r.Context(), name)
if err != nil {
utils.Error(w, http.StatusNotFound, fmt.Errorf("no such quadlet: %s: %w", name, err))
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(quadletContents)); err != nil {
logrus.Errorf("Failed to write quadlet contents: %v", err)
return
}
}
// QuadletExists checks if a quadlet exists by name
func QuadletExists(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
name := utils.GetName(r)
containerEngine := abi.ContainerEngine{Libpod: runtime}
report, err := containerEngine.QuadletExists(r.Context(), name)
if err != nil {
utils.InternalServerError(w, err)
return
}
if !report.Value {
utils.Error(w, http.StatusNotFound, fmt.Errorf("no such quadlet: %s", name))
return
}
utils.WriteResponse(w, http.StatusNoContent, "")
}
// extractQuadletFiles extracts quadlet files from tar archive to a temporary directory
func extractQuadletFiles(tempDir string, r io.ReadCloser) ([]string, error) {
quadletDir := filepath.Join(tempDir, "quadlets")
err := os.Mkdir(quadletDir, 0o700)
if err != nil {
return nil, err
}
err = chrootarchive.Untar(r, quadletDir, nil)
if err != nil {
return nil, err
}
// Collect all files from the extracted directory
var filePaths []string
err = filepath.WalkDir(quadletDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
filePaths = append(filePaths, path)
}
return nil
})
return filePaths, err
}
// processMultipartQuadlets processes multipart form data and saves files to temporary directory
func processMultipartQuadlets(tempDir string, r *http.Request) ([]string, error) {
quadletDir := filepath.Join(tempDir, "quadlets")
err := os.Mkdir(quadletDir, 0o700)
if err != nil {
return nil, err
}
reader, err := r.MultipartReader()
if err != nil {
return nil, fmt.Errorf("failed to create multipart reader: %w", err)
}
var filePaths []string
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read multipart: %w", err)
}
filename := part.FileName()
if filename == "" {
// Skip parts without filenames
_ = part.Close()
continue
}
filename = filepath.Base(filename)
if filename == "." || filename == ".." || filename == string(filepath.Separator) {
_ = part.Close()
continue
}
// Write the file in a scope that lets us close it per iteration
filePath := filepath.Join(quadletDir, filename)
if err := func() error {
defer part.Close()
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", filename, err)
}
defer file.Close()
_, err = io.Copy(file, part)
if err != nil {
return fmt.Errorf("failed to write file %s: %w", filename, err)
}
return nil
}(); err != nil {
return nil, err
}
filePaths = append(filePaths, filePath)
}
return filePaths, nil
}
func InstallQuadlets(w http.ResponseWriter, r *http.Request) {
// Create temporary directory for processing
contextDirectory, err := os.MkdirTemp("", "libpod_quadlet")
if err != nil {
utils.InternalServerError(w, err)
return
}
defer func() {
if err := os.RemoveAll(contextDirectory); err != nil {
logrus.Warn(fmt.Errorf("failed to remove libpod_quadlet tmp directory %q: %w", contextDirectory, err))
}
}()
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
// Parse query parameters
query := struct {
Replace bool `schema:"replace"`
ReloadSystemd bool `schema:"reload-systemd"`
Application string `schema:"application"`
}{
ReloadSystemd: true, // Default to true like CLI
}
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
return
}
multipart, err := utils.ValidateContentType(r)
if err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
var filePaths []string
if multipart {
logrus.Debug("Processing multipart form data")
filePaths, err = processMultipartQuadlets(contextDirectory, r)
if err != nil {
utils.InternalServerError(w, err)
return
}
} else {
logrus.Debug("Processing tar archive")
filePaths, err = extractQuadletFiles(contextDirectory, r.Body)
if err != nil {
utils.InternalServerError(w, err)
return
}
}
if len(filePaths) == 0 {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("no files found in request"))
return
}
countQuadletFiles := 0
for _, filePath := range filePaths {
isQuadletFile := quadlet.IsExtSupported(filePath)
if isQuadletFile {
countQuadletFiles++
}
}
if countQuadletFiles == 0 {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("no quadlet files found in request"))
return
}
// QuadletInstall expects the quadlet file to be first in the list.
// filepath.Walk returns files in lexicographic order, so a non-quadlet
// file (e.g. "Containerfile") may sort before the ".container" file.
for i, filePath := range filePaths {
if quadlet.IsExtSupported(filePath) {
filePaths[0], filePaths[i] = filePaths[i], filePaths[0]
break
}
}
containerEngine := abi.ContainerEngine{Libpod: runtime}
installOptions := entities.QuadletInstallOptions{
Replace: query.Replace,
Application: query.Application,
ReloadSystemd: query.ReloadSystemd,
}
installReport, err := containerEngine.QuadletInstall(r.Context(), filePaths, installOptions)
if err != nil {
utils.InternalServerError(w, err)
return
}
if len(installReport.QuadletErrors) > 0 {
var errs []error
for path, err := range installReport.QuadletErrors {
errs = append(errs, fmt.Errorf("%s: %w", path, err))
}
utils.Error(w, http.StatusBadRequest, fmt.Errorf("errors occurred installing some Quadlets: %w", errors.Join(errs...)))
return
}
utils.WriteResponse(w, http.StatusOK, installReport)
}
// RemoveQuadlet handles DELETE /libpod/quadlets/{name} to remove a quadlet file
func RemoveQuadlet(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
query := struct {
Force bool `schema:"force"`
Ignore bool `schema:"ignore"`
ReloadSystemd bool `schema:"reload-systemd"`
Recursive bool `schema:"recursive"`
}{
ReloadSystemd: true, // Default to true like CLI
}
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
return
}
name := utils.GetName(r)
if name == "" {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("quadlet name must be provided"))
return
}
containerEngine := abi.ContainerEngine{Libpod: runtime}
removeOptions := entities.QuadletRemoveOptions{
Force: query.Force,
Ignore: query.Ignore,
ReloadSystemd: query.ReloadSystemd,
Recursive: query.Recursive,
}
removeReport, err := containerEngine.QuadletRemove(r.Context(), []string{name}, removeOptions)
if err != nil {
// For systemd connection errors and other internal errors
utils.InternalServerError(w, err)
return
}
// Check if there are errors in the report for this specific quadlet
if err, ok := removeReport.Errors[name]; ok {
// If ignore=false and quadlet not found, return 404
if !query.Ignore && strings.Contains(err.Error(), "no such") {
utils.Error(w, http.StatusNotFound, fmt.Errorf("no such quadlet: %s: %w", name, err))
return
}
// If force=false and quadlet is running, return 400
if !query.Force && errors.Is(err, define.ErrQuadletRunning) {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("quadlet %s is running and force is not set, refusing to remove: %w", name, err))
return
}
}
utils.WriteResponse(w, http.StatusOK, removeReport)
}
// RemoveQuadlets handles DELETE /libpod/quadlets to remove quadlet files (batch operation)
func RemoveQuadlets(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
query := struct {
All bool `schema:"all"`
Force bool `schema:"force"`
Ignore bool `schema:"ignore"`
ReloadSystemd bool `schema:"reload-systemd"`
Recursive bool `schema:"recursive"`
Quadlets []string `schema:"quadlets"`
}{
ReloadSystemd: true, // Default to true like CLI
}
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
return
}
// Validate that either all=true OR at least one quadlet name is provided
if !query.All && len(query.Quadlets) == 0 {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("must provide at least 1 quadlet to remove or set all=true"))
return
}
// Validate that both all and quadlets are not provided together
if query.All && len(query.Quadlets) > 0 {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("when setting all=true, you may not pass any quadlet names"))
return
}
containerEngine := abi.ContainerEngine{Libpod: runtime}
removeOptions := entities.QuadletRemoveOptions{
Force: query.Force,
All: query.All,
Ignore: query.Ignore,
ReloadSystemd: query.ReloadSystemd,
Recursive: query.Recursive,
}
removeReport, err := containerEngine.QuadletRemove(r.Context(), query.Quadlets, removeOptions)
if err != nil {
// Check if it's a "must provide at least 1 quadlet" error (shouldn't happen due to validation above, but handle it)
if strings.Contains(err.Error(), "must provide at least 1 quadlet") {
utils.Error(w, http.StatusBadRequest, err)
return
}
// For systemd connection errors and other internal errors
utils.InternalServerError(w, err)
return
}
// Return 200 with the report containing errors (if any)
// The CLI behavior returns success even with partial errors
utils.WriteResponse(w, http.StatusOK, removeReport)
}