fix: fail closed thothctl secret sources

This commit is contained in:
2026-08-04 17:17:39 +02:00
parent 4158990c10
commit 35a000222c
7 changed files with 366 additions and 49 deletions
+52 -33
View File
@@ -2,6 +2,7 @@
package config
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
@@ -9,7 +10,11 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/aritmolab/thothii/tools/thothctl/internal/safeio"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
@@ -17,6 +22,8 @@ const installationFileName = "thothii-installation.yaml"
const maxEnvironmentFileBytes = 1 << 20
var dotenvParseMu sync.Mutex
type descriptor struct {
Profile string `yaml:"profile"`
ProjectDirectory string `yaml:"projectDirectory"`
@@ -119,31 +126,27 @@ func (i Installation) ComposeArgs(command ...string) []string {
return append(args, command...)
}
// SecretFiles returns only existing, absolute regular files declared in the installation env file
// through *_FILE or *_SOURCE variables. Missing paths are allowed because /run/secrets paths are
// container-local declarations, not host files thothctl can read.
// SecretFiles returns canonical local secret paths declared through *_FILE or *_SOURCE variables.
// Compose's dotenv parser resolves comments, quotes, escapes, and interpolation. Unsupported or
// unresolved source interpolation is rejected before thothctl invokes Docker.
func (i Installation) SecretFiles() ([]string, error) {
info, err := os.Stat(i.EnvFile)
if err != nil || info.Size() > maxEnvironmentFileBytes {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
contents, err := os.ReadFile(i.EnvFile)
if err != nil || len(contents) > maxEnvironmentFileBytes {
values, err := parseComposeDotenv(contents)
if err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
files := make([]string, 0)
files := make([]string, 0, len(values))
seen := make(map[string]struct{})
for _, line := range strings.Split(string(contents), "\n") {
key, value, ok := environmentAssignment(line)
if !ok || (!strings.HasSuffix(key, "_FILE") && !strings.HasSuffix(key, "_SOURCE")) || !filepath.IsAbs(value) {
for key, value := range values {
key = strings.ToUpper(key)
if !strings.HasSuffix(key, "_FILE") && !strings.HasSuffix(key, "_SOURCE") {
continue
}
fileInfo, err := os.Lstat(value)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil || !fileInfo.Mode().IsRegular() {
if err := safeio.ValidateCanonicalPath(value); err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
if _, exists := seen[value]; !exists {
@@ -154,25 +157,41 @@ func (i Installation) SecretFiles() ([]string, error) {
return files, nil
}
func environmentAssignment(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
func parseComposeDotenv(contents []byte) (map[string]string, error) {
dotenvParseMu.Lock()
defer dotenvParseMu.Unlock()
logger := logrus.StandardLogger()
previousOutput := logger.Out
previousHooks := logger.ReplaceHooks(make(logrus.LevelHooks))
logger.SetOutput(io.Discard)
warnings := &dotenvWarnings{}
logger.AddHook(warnings)
defer func() {
logger.SetOutput(previousOutput)
logger.ReplaceHooks(previousHooks)
}()
values, err := dotenv.ParseWithLookup(bytes.NewReader(contents), os.LookupEnv)
if err != nil || warnings.seen {
return nil, errors.New("dotenv parsing failed")
}
line = strings.TrimPrefix(line, "export ")
key, value, found := strings.Cut(line, "=")
if !found {
return "", "", false
return values, nil
}
type dotenvWarnings struct {
seen bool
}
func (w *dotenvWarnings) Levels() []logrus.Level {
return logrus.AllLevels
}
func (w *dotenvWarnings) Fire(entry *logrus.Entry) error {
if entry.Level == logrus.WarnLevel {
w.seen = true
}
key = strings.TrimSpace(key)
if key == "" {
return "", "", false
}
value = strings.TrimSpace(value)
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
value = value[1 : len(value)-1]
}
return strings.ToUpper(key), value, true
return nil
}
func ensureOnlyOneDocument(decoder *yaml.Decoder) error {