fix: fail closed thothctl secret sources
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -3,11 +3,11 @@ package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/safeio"
|
||||
)
|
||||
|
||||
var credentialField = regexp.MustCompile(`(?im)(\b[\w.-]*(?:password|token|key)[\w.-]*\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)`)
|
||||
@@ -48,18 +48,9 @@ func SecretValuesFromFiles(paths []string) ([]string, error) {
|
||||
}
|
||||
|
||||
func readSecretFile(path string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() > maxSecretFileBytes {
|
||||
return "", errors.New("declared secret file could not be read")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
contents, err := safeio.ReadCanonicalRegular(path, maxSecretFileBytes)
|
||||
if err != nil {
|
||||
return "", errors.New("declared secret file could not be read")
|
||||
}
|
||||
defer file.Close()
|
||||
contents, err := io.ReadAll(io.LimitReader(file, maxSecretFileBytes+1))
|
||||
if err != nil || len(contents) > maxSecretFileBytes {
|
||||
return "", errors.New("declared secret file could not be read")
|
||||
}
|
||||
return strings.TrimRight(string(contents), "\r\n"), nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestSanitizeRedactsPasswordTokenAndKeyFields(t *testing.T) {
|
||||
func TestSanitizeRedactsSecretFileContents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secretFile := filepath.Join(t.TempDir(), "provider-token")
|
||||
secretFile := filepath.Join(physicalTempDir(t), "provider-token")
|
||||
if err := os.WriteFile(secretFile, []byte("top-secret-value\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestSanitizeRedactsSecretFileContents(t *testing.T) {
|
||||
func TestSecretValuesFromFilesRejectsOversizedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secretFile := filepath.Join(t.TempDir(), "oversized-token")
|
||||
secretFile := filepath.Join(physicalTempDir(t), "oversized-token")
|
||||
if err := os.WriteFile(secretFile, make([]byte, 64*1024+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,3 +46,17 @@ func TestSecretValuesFromFilesRejectsOversizedFiles(t *testing.T) {
|
||||
t.Fatal("SecretValuesFromFiles() error = nil, want oversized-file error")
|
||||
}
|
||||
}
|
||||
|
||||
func physicalTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory, err := os.MkdirTemp(root, "thothctl-output-test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(directory) })
|
||||
return directory
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package safeio reads installation files without following symlinked path components.
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrUnsafeFile = errors.New("unsafe file")
|
||||
|
||||
// ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
|
||||
func ValidateCanonicalPath(path string) error {
|
||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadCanonicalRegular opens a canonical regular file after rejecting symlinked parents, then
|
||||
// bounds reads against the opened handle rather than a pre-open size check.
|
||||
func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectSymlinkComponents(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
contents, err := io.ReadAll(io.LimitReader(file, maximum+1))
|
||||
if err != nil || int64(len(contents)) > maximum {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func rejectSymlinkComponents(path string) error {
|
||||
volume := filepath.VolumeName(path)
|
||||
current := volume + string(filepath.Separator)
|
||||
relative := strings.TrimPrefix(path, current)
|
||||
for _, component := range strings.Split(relative, string(filepath.Separator)) {
|
||||
if component == "" {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user