Files
soft_quay/core/installer/extractor_test.go
T

549 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package installer
import (
"archive/zip"
"bytes"
"errors"
"math"
"os"
"path/filepath"
"testing"
)
func TestExtractorExtractsPayloadOnly(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{"entrypoint":"bin/App.exe"}`)},
{name: "files.json", body: []byte(`{"files":[]}`)},
{name: "payload/bin/", mode: os.ModeDir | 0o755},
{name: "payload/bin/App.exe", body: []byte("executable"), mode: 0o755},
{name: "payload/readme.txt", body: []byte("hello")},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(
archivePath,
destination,
"bin/App.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 2 {
t.Fatalf("Files = %d, want 2", result.Files)
}
if result.Bytes != int64(len("executable")+len("hello")) {
t.Fatalf("Bytes = %d, want %d", result.Bytes, len("executable")+len("hello"))
}
if _, err := os.Stat(result.EntrypointPath); err != nil {
t.Fatalf("entrypoint stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(destination, "app.json")); !os.IsNotExist(err) {
t.Fatalf("app.json should not be extracted, stat error = %v", err)
}
}
func TestExtractorRejectsAttackArchives(t *testing.T) {
base := []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
}
tests := []struct {
name string
entries []testZIPEntry
entrypoint string
limits Limits
wantErr error
}{
{
name: "absolute path",
entries: appendEntries(base,
testZIPEntry{name: "/payload/evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "drive path",
entries: appendEntries(base,
testZIPEntry{name: "C:/payload/evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "ADS path",
entries: appendEntries(base,
testZIPEntry{name: "payload/App.exe:stream", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "dot dot traversal",
entries: appendEntries(base,
testZIPEntry{name: "payload/../evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "Windows normalized dot dot traversal",
entries: appendEntries(base,
testZIPEntry{name: "payload/.. /escape.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "trailing period",
entries: appendEntries(base,
testZIPEntry{name: "payload/evil.exe.", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "trailing space",
entries: appendEntries(base,
testZIPEntry{name: "payload/evil.exe ", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "leading space",
entries: appendEntries(base,
testZIPEntry{name: "payload/ evil.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "reserved device",
entries: appendEntries(base,
testZIPEntry{name: "payload/NUL.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "reserved device with space before extension",
entries: appendEntries(base,
testZIPEntry{name: "payload/NUL .txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "reserved device superscript",
entries: appendEntries(base,
testZIPEntry{name: "payload/COM¹.log", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "forbidden Windows character",
entries: appendEntries(base,
testZIPEntry{name: "payload/evil?.exe", body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "backslash traversal",
entries: appendEntries(base,
testZIPEntry{name: `payload\..\evil.exe`, body: []byte("x")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrPathEscape,
},
{
name: "encrypted entry",
entries: appendEntries(base,
testZIPEntry{name: "payload/secret.bin", body: []byte("x"), flags: 0x1}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrEncryptedEntry,
},
{
name: "symlink",
entries: appendEntries(base,
testZIPEntry{
name: "payload/link",
body: []byte("../../outside"),
mode: os.ModeSymlink | 0o777,
}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrUnsupportedEntry,
},
{
name: "special file",
entries: appendEntries(base,
testZIPEntry{name: "payload/pipe", mode: os.ModeNamedPipe | 0o600}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrUnsupportedEntry,
},
{
name: "case folded duplicate",
entries: appendEntries(base,
testZIPEntry{name: "payload/app.exe", body: []byte("duplicate")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrDuplicateEntry,
},
{
name: "Unicode folded duplicate",
entries: []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/K.exe", body: []byte("one")},
{name: "payload/K.exe", body: []byte("two")},
},
entrypoint: "K.exe",
limits: testLimits(),
wantErr: ErrDuplicateEntry,
},
{
name: "unexpected top level entry",
entries: appendEntries(base,
testZIPEntry{name: "install.bat", body: []byte("echo unsafe")}),
entrypoint: "App.exe",
limits: testLimits(),
wantErr: ErrUnexpectedEntry,
},
{
name: "too many entries",
entries: appendEntries(base,
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 2,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
},
wantErr: ErrTooManyEntries,
},
{
name: "expanded size",
entries: []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("0123456789")},
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
},
wantErr: ErrExpandedTooLarge,
},
{
name: "compression ratio",
entries: []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{
name: "payload/App.exe",
body: bytes.Repeat([]byte("A"), 4096),
method: zip.Deflate,
},
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
},
wantErr: ErrCompressionRatio,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeTestZIP(t, test.entries)
root := t.TempDir()
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("rejected archive left staging, stat error = %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) {
t.Fatalf("rejected archive wrote outside staging, stat error = %v", statErr)
}
})
}
}
func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
tests := []struct {
entrypoint string
wantErr error
}{
{entrypoint: "../App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "/App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: `..\App.exe`, wantErr: ErrEntrypointInvalid},
{entrypoint: ".. /App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "App.exe.", wantErr: ErrEntrypointInvalid},
{entrypoint: "App.exe ", wantErr: ErrEntrypointInvalid},
{entrypoint: " App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "NUL", wantErr: ErrEntrypointInvalid},
{entrypoint: "bad?.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "payload/App.exe", wantErr: ErrEntrypointInvalid},
{entrypoint: "Missing.exe", wantErr: ErrEntrypointMissing},
}
for _, test := range tests {
t.Run(test.entrypoint, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("invalid entrypoint left staging, stat error = %v", statErr)
}
})
}
}
func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/工具/解析器.exe", body: []byte("executable")},
{name: "payload/资源/说明.txt", body: []byte("说明")},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(
archivePath,
destination,
"工具/解析器.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 2 {
t.Fatalf("Files = %d, want 2", result.Files)
}
if _, err := os.Stat(result.EntrypointPath); err != nil {
t.Fatalf("entrypoint stat error = %v", err)
}
}
func TestExtractorRejectsExistingDestination(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
destination := filepath.Join(t.TempDir(), "staging")
if err := os.Mkdir(destination, 0o700); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrDestinationExists) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
}
}
func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
})
corruptZIPEntryData(t, archivePath, "payload/App.exe")
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrArchiveCorrupt) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("copy failure left staging, stat error = %v", statErr)
}
}
type testZIPEntry struct {
name string
body []byte
mode os.FileMode
method uint16
flags uint16
}
func testLimits() Limits {
return Limits{
MaxEntries: 20,
MaxArchiveBytes: 64 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
}
}
func archiveSize(t *testing.T, archivePath string) int64 {
t.Helper()
info, err := os.Stat(archivePath)
if err != nil {
t.Fatalf("stat archive: %v", err)
}
return info.Size()
}
func mustExtractor(t *testing.T, limits Limits) Extractor {
t.Helper()
extractor, err := NewExtractor(limits)
if err != nil {
t.Fatalf("NewExtractor() error = %v", err)
}
return extractor
}
func appendEntries(base []testZIPEntry, extra ...testZIPEntry) []testZIPEntry {
result := append([]testZIPEntry(nil), base...)
return append(result, extra...)
}
func writeTestZIP(t *testing.T, entries []testZIPEntry) string {
t.Helper()
path := filepath.Join(t.TempDir(), "package.zip")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create ZIP: %v", err)
}
writer := zip.NewWriter(file)
for _, entry := range entries {
header := &zip.FileHeader{
Name: entry.name,
Method: entry.method,
Flags: entry.flags,
}
mode := entry.mode
if mode == 0 {
mode = 0o600
}
header.SetMode(mode)
part, err := writer.CreateHeader(header)
if err != nil {
writer.Close()
file.Close()
t.Fatalf("create ZIP entry %s: %v", entry.name, err)
}
if _, err := part.Write(entry.body); err != nil {
writer.Close()
file.Close()
t.Fatalf("write ZIP entry %s: %v", entry.name, err)
}
}
if err := writer.Close(); err != nil {
file.Close()
t.Fatalf("close ZIP writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close ZIP file: %v", err)
}
return path
}
func corruptZIPEntryData(t *testing.T, archivePath, entryName string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open ZIP for corruption: %v", err)
}
var offset int64 = -1
for _, file := range reader.File {
if file.Name == entryName {
offset, err = file.DataOffset()
if err != nil {
reader.Close()
t.Fatalf("entry data offset: %v", err)
}
break
}
}
if err := reader.Close(); err != nil {
t.Fatalf("close ZIP reader: %v", err)
}
if offset < 0 {
t.Fatalf("entry %s not found", entryName)
}
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP for corruption: %v", err)
}
data[offset] ^= 0xff
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write corrupted ZIP: %v", err)
}
}
func TestDefaultLimitsAreValid(t *testing.T) {
if _, err := NewExtractor(DefaultLimits()); err != nil {
t.Fatalf("NewExtractor(DefaultLimits()) error = %v", err)
}
}
func TestExtractorRejectsInvalidLimits(t *testing.T) {
tests := []Limits{
{MaxEntries: 0, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 0, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 2, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
}
for index, limits := range tests {
if _, err := NewExtractor(limits); !errors.Is(err, ErrInvalidLimits) {
t.Errorf("case %d NewExtractor() error = %v, want %v", index, err, ErrInvalidLimits)
}
}
}