Files
blacktop c20f9af712 fix: multiple security vulnerabilities (SSRF, path traversal)
- Remove POST /diff/files endpoint that allowed arbitrary host file reads
  (CWE-22); clients must now read files locally and POST content to
  /diff/blobs instead
- Add validatePublicURL() to /info remote endpoints, rejecting URLs that
  resolve to loopback, private, link-local, or multicast addresses to
  prevent SSRF; also remove attacker-controlled proxy/insecure params
- Add SanitizeArchivePath() helper that verifies extracted archive entry
  paths stay within the destination directory (zip-slip / tar-slip,
  CWE-22); replace bare filepath.Join(dest, filepath.Clean(name)) calls
  in SearchZip, OTA parser, AA payload extractor, and ota_extract
- Fix server listen address to use net.JoinHostPort to respect Host config
2026-04-11 14:37:41 -06:00

538 lines
14 KiB
Go

package utils
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha1"
"errors"
"fmt"
"io"
"maps"
"math/rand"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/apex/log"
"github.com/apex/log/handlers/cli"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
)
var normalPadding = cli.Default.Padding
// Retry will retry a function f a number of attempts with a sleep duration in between
func Retry(attempts int, sleep time.Duration, f func() error) error {
_, err := RetryWithResult(attempts, sleep, func() (struct{}, error) {
return struct{}{}, f()
})
return err
}
type StopRetryingError struct {
Err error
}
func (e *StopRetryingError) Error() string {
return e.Err.Error()
}
// RetryWithResult will retry a function f a number of attempts with a sleep duration in between, returning the result if successful
func RetryWithResult[T any](attempts int, sleep time.Duration, f func() (T, error)) (result T, err error) {
for i := range attempts {
if i > 0 {
Indent(log.Debug, 2)(fmt.Sprintf("retrying after error: %s", err))
time.Sleep(sleep)
sleep *= 2
}
result, err = f()
if err == nil {
return result, nil
}
// If StopRetryingError is returned, unwrap and return immediately
var stopErr *StopRetryingError
if errors.As(err, &stopErr) {
return result, stopErr.Err
}
}
return result, fmt.Errorf("after %d attempts, last error: %s", attempts, err)
}
func RandomAgent() string {
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36",
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:56.0) Gecko/20100101 Firefox/56.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38",
}
return userAgents[rand.Int()%len(userAgents)]
}
// Indent indents apex log line to supplied level
func Indent(f func(s string), level int) func(string) {
return func(s string) {
cli.Default.Padding = normalPadding * level
f(s)
cli.Default.Padding = normalPadding
}
}
// Uint64SliceContains returns true if uint64 slice contains given uint64
func Uint64SliceContains(slice []uint64, item uint64) bool {
return slices.Contains(slice, item)
}
// Unique returns a slice with only unique elements
func Unique[T comparable](s []T) []T {
inResult := make(map[T]bool)
var result []T
for _, str := range s {
if _, ok := inResult[str]; !ok {
inResult[str] = true
result = append(result, str)
}
}
return result
}
func UniqueAppend[T comparable](slice []T, i T) []T {
if slices.Contains(slice, i) {
return slice
}
return append(slice, i)
}
func UniqueConcat[T comparable](slice []T, in []T) []T {
for _, i := range in {
if slices.Contains(slice, i) {
return slice
}
slice = append(slice, i)
}
return slice
}
type Pair[T, U any] struct {
First T
Second U
}
func Zip[T, U any](ts []T, us []U) ([]Pair[T, U], error) {
if len(ts) != len(us) {
return nil, fmt.Errorf("slices have different lengths")
}
pairs := make([]Pair[T, U], len(ts))
for i := range ts {
pairs[i] = Pair[T, U]{ts[i], us[i]}
}
return pairs, nil
}
func Reverse[T any](input []T) {
for i, j := 0, len(input)-1; i < j; i, j = i+1, j-1 {
input[i], input[j] = input[j], input[i]
}
}
func Difference[T comparable](a []T, b []T) []T {
amap := make(map[T]bool)
for _, item := range a {
amap[item] = true
}
for _, item := range b {
delete(amap, item)
}
return slices.Collect(maps.Keys(amap))
}
// ReverseBytes reverse byte array order
func ReverseBytes(a []byte) []byte {
for i := len(a)/2 - 1; i >= 0; i-- {
opp := len(a) - 1 - i
a[i], a[opp] = a[opp], a[i]
}
return a
}
// Verify verifies the downloaded against it's hash
func Verify(sha1sum, name string) (bool, error) {
f, err := os.Open(name)
if err != nil {
return false, err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return false, err
}
match := strings.EqualFold(sha1sum, fmt.Sprintf("%x", h.Sum(nil)))
if !match {
Indent(log.WithFields(log.Fields{
"expected": sha1sum,
"actual": fmt.Sprintf("%x", h.Sum(nil)),
}).Error, 3)("BAD CHECKSUM")
}
return match, nil
}
// SanitizeArchivePath joins an untrusted archive entry name to a destination
// directory and verifies the result stays within that directory. Returns an
// error if the entry name would escape via path traversal (zip-slip / tar-slip).
//
// This must be used instead of filepath.Join(dest, filepath.Clean(name)) when
// name comes from an archive — filepath.Clean does NOT strip leading "../".
//
// Lexical check only: does NOT resolve symlinks. If dest already contains a
// symlink pointing outside, writes through it will escape. Callers must not
// create symlinks from untrusted archive entries (SearchZip writes them as
// regular files; UnTarGz rejects tar.TypeSymlink via the default case).
func SanitizeArchivePath(dest, name string) (string, error) {
target := filepath.Join(dest, name)
absDest, err := filepath.Abs(dest)
if err != nil {
return "", fmt.Errorf("failed to resolve destination: %w", err)
}
absTarget, err := filepath.Abs(target)
if err != nil {
return "", fmt.Errorf("failed to resolve target: %w", err)
}
rel, err := filepath.Rel(absDest, absTarget)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("archive entry %q escapes destination directory", name)
}
return target, nil
}
// SearchZip searches for files in a zip.Reader
func SearchZip(files []*zip.File, pattern *regexp.Regexp, folder string, flat, progress bool) ([]string, error) {
var fname string
var artifacts []string
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current working directory: %v", err)
}
found := false
for _, f := range files {
if pattern.MatchString(f.Name) {
if f.FileInfo().IsDir() {
continue
}
found = true
if flat {
fname = filepath.Join(folder, filepath.Base(f.Name))
} else {
fname, err = SanitizeArchivePath(folder, f.Name)
if err != nil {
return nil, err
}
}
if err := os.MkdirAll(filepath.Dir(fname), 0750); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %v", filepath.Dir(fname), err)
}
var r io.ReadCloser
if _, err := os.Stat(fname); os.IsNotExist(err) {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("error opening remote zipped file %s: %v", f.Name, err)
}
defer rc.Close()
var p *mpb.Progress
if progress {
// setup progress bar
var total = int64(f.UncompressedSize64)
p = mpb.New(
mpb.WithWidth(60),
mpb.WithRefreshRate(180*time.Millisecond),
)
bar := p.New(total,
mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding("-").Rbound("|"),
mpb.PrependDecorators(
decor.CountersKibiByte("\t% .2f / % .2f"),
),
mpb.AppendDecorators(
decor.OnComplete(decor.AverageETA(decor.ET_STYLE_GO), "✅ "),
decor.Name(" ] "),
decor.AverageSpeed(decor.SizeB1024(0), "% .2f", decor.WCSyncWidth),
),
)
// create proxy reader
r = bar.ProxyReader(io.LimitReader(rc, total))
defer r.Close()
} else {
r = rc
}
Indent(log.Debug, 2)(fmt.Sprintf("Extracting %s", strings.TrimPrefix(fname, cwd)))
out, err := os.Create(fname)
if err != nil {
return nil, fmt.Errorf("error creating remote unzipped file destination %s: %v", fname, err)
}
defer out.Close()
io.Copy(out, r)
if progress {
// wait for our bar to complete and flush and close remote zip and temp file
p.Wait()
}
artifacts = append(artifacts, fname)
} else {
Indent(log.Warn, 2)(fmt.Sprintf("%s already exists", fname))
artifacts = append(artifacts, fname)
}
}
}
if !found {
return nil, fmt.Errorf("no files found matching %s", pattern.String())
}
return artifacts, nil
}
// SearchPartialZip searches for files in a zip.Reader and returns a byte slice of the file
func SearchPartialZip(files []*zip.File, pattern *regexp.Regexp, folder string, size int64, flat, progress bool) ([]string, error) {
var fname string
var artifacts []string
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current working directory: %v", err)
}
found := false
for _, f := range files {
if pattern.MatchString(f.Name) {
if f.FileInfo().IsDir() {
continue
}
found = true
if flat {
fname = filepath.Join(folder, filepath.Base(f.Name))
} else {
fname, err = SanitizeArchivePath(folder, f.Name)
if err != nil {
return nil, err
}
}
if err := os.MkdirAll(filepath.Dir(fname), 0750); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %v", filepath.Dir(fname), err)
}
var r io.ReadCloser
if _, err := os.Stat(fname); os.IsNotExist(err) {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("error opening remote zipped file %s: %v", f.Name, err)
}
defer rc.Close()
var p *mpb.Progress
if progress {
// setup progress bar
var total = int64(size)
p = mpb.New(
mpb.WithWidth(60),
mpb.WithRefreshRate(180*time.Millisecond),
)
bar := p.New(total,
mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding("-").Rbound("|"),
mpb.PrependDecorators(
decor.CountersKibiByte("\t% .2f / % .2f"),
),
mpb.AppendDecorators(
decor.OnComplete(decor.AverageETA(decor.ET_STYLE_GO), "✅ "),
decor.Name(" ] "),
decor.AverageSpeed(decor.SizeB1024(0), "% .2f", decor.WCSyncWidth),
),
)
// create proxy reader
r = bar.ProxyReader(io.LimitReader(rc, total))
defer r.Close()
} else {
r = rc
}
Indent(log.Debug, 2)(fmt.Sprintf("Extracting %#x bytes of %s", size, strings.TrimPrefix(fname, cwd)))
out, err := os.Create(fname)
if err != nil {
return nil, fmt.Errorf("error creating remote unzipped file destination %s: %v", fname, err)
}
defer out.Close()
io.CopyN(out, r, size)
if progress {
// wait for our bar to complete and flush and close remote zip and temp file
p.Wait()
}
artifacts = append(artifacts, fname)
} else {
Indent(log.Warn, 2)(fmt.Sprintf("%s already exists", fname))
artifacts = append(artifacts, fname)
}
}
}
if !found {
return nil, fmt.Errorf("no files found matching %s", pattern.String())
}
return artifacts, nil
}
// Unzip - https://stackoverflow.com/a/24792688
func Unzip(src, dest string, filter func(f *zip.File) bool) ([]string, error) {
var fNames []string
r, err := zip.OpenReader(src)
if err != nil {
return nil, err
}
defer func() {
if err := r.Close(); err != nil {
panic(err)
}
}()
os.MkdirAll(dest, 0750)
// Closure to address file descriptors issue with all the deferred .Close() methods
extractAndWriteFile := func(f *zip.File) error {
rc, err := f.Open()
if err != nil {
return err
}
defer func() {
if err := rc.Close(); err != nil {
panic(err)
}
}()
path := filepath.Join(dest, filepath.Base(filepath.Clean(f.Name)))
if f.FileInfo().IsDir() {
os.MkdirAll(path, 0750)
} else {
// TODO: add the ability to preserve folder structure if user wants
// os.MkdirAll(filepath.Dir(path), 0750)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer func() {
if err := f.Close(); err != nil {
panic(err)
}
}()
Indent(log.Debug, 2)(fmt.Sprintf("Extracted %s from %s", path, filepath.Base(src)))
_, err = io.Copy(f, rc)
if err != nil {
return err
}
}
return nil
}
for _, f := range r.File {
if filter(f) {
fNames = append(fNames, filepath.Join(dest, filepath.Base(filepath.Clean(f.Name))))
err := extractAndWriteFile(f)
if err != nil {
return nil, err
}
}
}
return fNames, nil
}
// UnTarGz - https://stackoverflow.com/a/57640231
func UnTarGz(tarfile, destPath string) error {
r, err := os.Open(tarfile)
if err != nil {
return err
}
uncompressedStream, err := gzip.NewReader(r)
if err != nil {
return err
}
tarReader := tar.NewReader(uncompressedStream)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tarReader.Next() failed: %v", err)
}
target, err := SanitizeArchivePath(destPath, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("os.MkdirAll failed: %v", err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("os.MkdirAll failed: %v", err)
}
outFile, err := os.Create(target)
if err != nil {
return fmt.Errorf("os.Create failed: %v", err)
}
defer outFile.Close()
if _, err := io.Copy(outFile, tarReader); err != nil {
return fmt.Errorf("io.Copy failed: %v", err)
}
default:
return fmt.Errorf("uknown type: %v in %s",
header.Typeflag,
header.Name)
}
}
return nil
}
func Sha1(in string) (string, error) {
f, err := os.Open(in)
if err != nil {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}