From 0ffeff63e138fcb800f0eee0a8a1d4167f20df09 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 16:22:45 +0800 Subject: [PATCH] Prototype secure ZIP extraction (T-102) --- core/installer/extractor.go | 359 ++++++++++++++++++++++++++++ core/installer/extractor_test.go | 392 +++++++++++++++++++++++++++++++ core/installer/limits.go | 45 ++++ docs/04-architecture.md | 2 + docs/api.md | 8 + docs/current-state.md | 18 +- docs/tasks/T-102.md | 64 +++++ testdata/zip/README.md | 11 + 8 files changed, 890 insertions(+), 9 deletions(-) create mode 100644 core/installer/extractor.go create mode 100644 core/installer/extractor_test.go create mode 100644 core/installer/limits.go create mode 100644 docs/tasks/T-102.md create mode 100644 testdata/zip/README.md diff --git a/core/installer/extractor.go b/core/installer/extractor.go new file mode 100644 index 0000000..d61e4fc --- /dev/null +++ b/core/installer/extractor.go @@ -0,0 +1,359 @@ +package installer + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "math" + "os" + "path" + "path/filepath" + "strings" + "unicode/utf8" +) + +var ( + ErrInvalidArchive = errors.New("invalid ZIP archive") + ErrPathEscape = errors.New("ZIP path escapes payload") + ErrUnsupportedEntry = errors.New("unsupported ZIP entry") + ErrUnexpectedEntry = errors.New("unexpected ZIP package entry") + ErrDuplicateEntry = errors.New("duplicate ZIP entry") + ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported") + ErrTooManyEntries = errors.New("ZIP entry limit exceeded") + ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded") + ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded") + ErrEntrypointInvalid = errors.New("invalid package entrypoint") + ErrEntrypointMissing = errors.New("package entrypoint is missing") + ErrAppManifestMissing = errors.New("package app.json is missing") + ErrDestinationExists = errors.New("staging destination already exists") + ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt") +) + +// Extractor writes only payload/ contents from a pre-verified package ZIP. +type Extractor struct { + limits Limits +} + +type ExtractResult struct { + Files int + Bytes int64 + EntrypointPath string +} + +type plannedEntry struct { + file *zip.File + archivePath string + outputPath string + directory bool +} + +func NewExtractor(limits Limits) (Extractor, error) { + if err := limits.validate(); err != nil { + return Extractor{}, err + } + return Extractor{limits: limits}, nil +} + +// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks. +func (extractor Extractor) ExtractFile( + zipPath string, + destination string, + entrypoint string, +) (ExtractResult, error) { + archive, err := zip.OpenReader(zipPath) + if err != nil { + return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err) + } + defer archive.Close() + return extractor.extract(&archive.Reader, destination, entrypoint) +} + +func (extractor Extractor) extract( + archive *zip.Reader, + destination string, + entrypoint string, +) (result ExtractResult, err error) { + if err := extractor.limits.validate(); err != nil { + return ExtractResult{}, err + } + normalizedEntrypoint, err := normalizeEntrypoint(entrypoint) + if err != nil { + return ExtractResult{}, err + } + plan, err := extractor.preflight(archive, normalizedEntrypoint) + if err != nil { + return ExtractResult{}, err + } + + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return ExtractResult{}, fmt.Errorf("create staging parent: %w", err) + } + if err := os.Mkdir(destination, 0o700); err != nil { + if os.IsExist(err) { + return ExtractResult{}, ErrDestinationExists + } + return ExtractResult{}, fmt.Errorf("create staging destination: %w", err) + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(destination) + } + }() + + var written int64 + for _, entry := range plan { + target := filepath.Join(destination, filepath.FromSlash(entry.outputPath)) + if entry.directory { + if entry.outputPath == "" { + continue + } + if err := os.MkdirAll(target, 0o700); err != nil { + return ExtractResult{}, fmt.Errorf("create staging directory: %w", err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err) + } + + source, err := entry.file.Open() + if err != nil { + return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err) + } + mode := os.FileMode(0o600) + if entry.file.Mode().Perm()&0o111 != 0 { + mode = 0o700 + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + source.Close() + return ExtractResult{}, fmt.Errorf("create staging file: %w", err) + } + + remaining := extractor.limits.MaxUncompressedBytes - written + readLimit := remaining + if readLimit < math.MaxInt64 { + readLimit++ + } + copied, copyErr := io.Copy(output, io.LimitReader(source, readLimit)) + closeOutputErr := output.Close() + closeSourceErr := source.Close() + if copyErr != nil { + return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr) + } + if closeOutputErr != nil { + return ExtractResult{}, fmt.Errorf("close staging file: %w", closeOutputErr) + } + if closeSourceErr != nil { + return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr) + } + if copied > remaining { + return ExtractResult{}, ErrExpandedTooLarge + } + if uint64(copied) != entry.file.UncompressedSize64 { + return ExtractResult{}, fmt.Errorf( + "%w: %s expanded to %d bytes, header declares %d", + ErrArchiveCorrupt, + entry.archivePath, + copied, + entry.file.UncompressedSize64, + ) + } + written += copied + result.Files++ + } + + result.Bytes = written + result.EntrypointPath = filepath.Join( + destination, + filepath.FromSlash(normalizedEntrypoint), + ) + complete = true + return result, nil +} + +func (extractor Extractor) preflight( + archive *zip.Reader, + entrypoint string, +) ([]plannedEntry, error) { + if len(archive.File) > extractor.limits.MaxEntries { + return nil, fmt.Errorf( + "%w: got %d, limit %d", + ErrTooManyEntries, + len(archive.File), + extractor.limits.MaxEntries, + ) + } + + entrypointArchivePath := "payload/" + entrypoint + seenPaths := make(map[string]string, len(archive.File)) + plan := make([]plannedEntry, 0, len(archive.File)) + var totalUncompressed uint64 + var totalCompressed uint64 + appManifestFound := false + entrypointFound := false + + for _, file := range archive.File { + normalized, directory, err := validateArchiveEntry(file) + if err != nil { + return nil, err + } + folded := strings.ToLower(normalized) + if previous, exists := seenPaths[folded]; exists { + return nil, fmt.Errorf( + "%w: %q conflicts with %q", + ErrDuplicateEntry, + normalized, + previous, + ) + } + seenPaths[folded] = normalized + + if file.UncompressedSize64 > uint64(extractor.limits.MaxUncompressedBytes)-totalUncompressed { + return nil, ErrExpandedTooLarge + } + totalUncompressed += file.UncompressedSize64 + if ^uint64(0)-totalCompressed < file.CompressedSize64 { + return nil, fmt.Errorf("%w: compressed size overflow", ErrInvalidArchive) + } + totalCompressed += file.CompressedSize64 + if exceedsCompressionRatio( + file.UncompressedSize64, + file.CompressedSize64, + extractor.limits.MaxCompressionRatio, + ) { + return nil, fmt.Errorf("%w: %s", ErrCompressionRatio, normalized) + } + + switch { + case normalized == "app.json": + if directory { + return nil, fmt.Errorf("%w: app.json is a directory", ErrUnexpectedEntry) + } + appManifestFound = true + case normalized == "files.json": + if directory { + return nil, fmt.Errorf("%w: files.json is a directory", ErrUnexpectedEntry) + } + case normalized == "payload": + if !directory { + return nil, fmt.Errorf("%w: payload must be a directory", ErrUnexpectedEntry) + } + plan = append(plan, plannedEntry{ + file: file, + archivePath: normalized, + outputPath: "", + directory: true, + }) + case strings.HasPrefix(normalized, "payload/"): + outputPath := strings.TrimPrefix(normalized, "payload/") + plan = append(plan, plannedEntry{ + file: file, + archivePath: normalized, + outputPath: outputPath, + directory: directory, + }) + if normalized == entrypointArchivePath && !directory { + entrypointFound = true + } + default: + return nil, fmt.Errorf("%w: %s", ErrUnexpectedEntry, normalized) + } + } + + if !appManifestFound { + return nil, ErrAppManifestMissing + } + if exceedsCompressionRatio( + totalUncompressed, + totalCompressed, + extractor.limits.MaxCompressionRatio, + ) { + return nil, fmt.Errorf("%w: whole archive", ErrCompressionRatio) + } + if !entrypointFound { + return nil, fmt.Errorf("%w: %s", ErrEntrypointMissing, entrypoint) + } + return plan, nil +} + +func validateArchiveEntry(file *zip.File) (string, bool, error) { + if file.Flags&0x1 != 0 { + return "", false, fmt.Errorf("%w: %s", ErrEncryptedEntry, file.Name) + } + normalized, directory, err := normalizeArchivePath(file.Name) + if err != nil { + return "", false, err + } + + mode := file.Mode() + if mode&os.ModeSymlink != 0 { + return "", false, fmt.Errorf("%w: symlink %s", ErrUnsupportedEntry, normalized) + } + if directory { + if !mode.IsDir() { + return "", false, fmt.Errorf("%w: non-directory mode for %s", ErrUnsupportedEntry, normalized) + } + return normalized, true, nil + } + if !mode.IsRegular() { + return "", false, fmt.Errorf("%w: special file %s", ErrUnsupportedEntry, normalized) + } + return normalized, false, nil +} + +func normalizeArchivePath(name string) (string, bool, error) { + if name == "" || !utf8.ValidString(name) || strings.ContainsRune(name, '\x00') { + return "", false, fmt.Errorf("%w: invalid entry name", ErrPathEscape) + } + if strings.Contains(name, `\`) || strings.Contains(name, ":") { + return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) + } + directory := strings.HasSuffix(name, "/") + trimmed := strings.TrimSuffix(name, "/") + if trimmed == "" || path.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") { + return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) + } + cleaned := path.Clean(trimmed) + if cleaned != trimmed || + cleaned == "." || + cleaned == ".." || + strings.HasPrefix(cleaned, "../") { + return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) + } + return cleaned, directory, nil +} + +func normalizeEntrypoint(entrypoint string) (string, error) { + if entrypoint == "" || + !utf8.ValidString(entrypoint) || + strings.ContainsRune(entrypoint, '\x00') || + strings.Contains(entrypoint, `\`) || + strings.Contains(entrypoint, ":") || + strings.HasSuffix(entrypoint, "/") || + path.IsAbs(entrypoint) { + return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint) + } + cleaned := path.Clean(entrypoint) + if cleaned != entrypoint || + cleaned == "." || + cleaned == ".." || + strings.HasPrefix(cleaned, "../") || + cleaned == "payload" || + strings.HasPrefix(cleaned, "payload/") { + return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint) + } + return cleaned, nil +} + +func exceedsCompressionRatio(uncompressed, compressed uint64, maximum float64) bool { + if uncompressed == 0 { + return false + } + if compressed == 0 { + return true + } + return float64(uncompressed)/float64(compressed) > maximum +} diff --git a/core/installer/extractor_test.go b/core/installer/extractor_test.go new file mode 100644 index 0000000..b047900 --- /dev/null +++ b/core/installer/extractor_test.go @@ -0,0 +1,392 @@ +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") + 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: "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: "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, + 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, + 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, + MaxUncompressedBytes: 8192, + MaxCompressionRatio: 2, + }, + wantErr: ErrCompressionRatio, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + archivePath := writeTestZIP(t, test.entries) + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, test.limits) + + _, err := extractor.ExtractFile(archivePath, destination, test.entrypoint) + 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) + } + }) + } +} + +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: "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) + 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 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") + 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") + 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, + MaxUncompressedBytes: 16 * 1024, + MaxCompressionRatio: 100, + } +} + +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, MaxUncompressedBytes: 1, MaxCompressionRatio: 1}, + {MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1}, + {MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0}, + {MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()}, + {MaxEntries: 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) + } + } +} diff --git a/core/installer/limits.go b/core/installer/limits.go new file mode 100644 index 0000000..b0ca1a9 --- /dev/null +++ b/core/installer/limits.go @@ -0,0 +1,45 @@ +package installer + +import ( + "errors" + "fmt" + "math" +) + +var ErrInvalidLimits = errors.New("invalid ZIP extraction limits") + +const ( + DefaultMaxEntries = 10_000 + DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024) + DefaultMaxCompressionRatio = 200.0 +) + +// Limits bounds archive metadata and decompressed output. +type Limits struct { + MaxEntries int + MaxUncompressedBytes int64 + MaxCompressionRatio float64 +} + +func DefaultLimits() Limits { + return Limits{ + MaxEntries: DefaultMaxEntries, + MaxUncompressedBytes: DefaultMaxUncompressedBytes, + MaxCompressionRatio: DefaultMaxCompressionRatio, + } +} + +func (limits Limits) validate() error { + if limits.MaxEntries <= 0 { + return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits) + } + if limits.MaxUncompressedBytes <= 0 { + return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits) + } + if limits.MaxCompressionRatio <= 0 || + math.IsNaN(limits.MaxCompressionRatio) || + math.IsInf(limits.MaxCompressionRatio, 0) { + return fmt.Errorf("%w: MaxCompressionRatio must be finite and positive", ErrInvalidLimits) + } + return nil +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index cbb738b..fd65ec5 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -147,6 +147,8 @@ soft_quay/ 必须防止:绝对路径、`../` 穿越、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 +Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、路径、类型、重复项、entrypoint 与资源上限,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。 + 盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。 diff --git a/docs/api.md b/docs/api.md index 8dd2075..9182570 100644 --- a/docs/api.md +++ b/docs/api.md @@ -119,6 +119,14 @@ json-parser_1.4.2_windows_amd64.zip 绝对路径;`../` 穿越;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。 +T-102 Phase 1 原型进一步固定: + +- ZIP 名称只接受 UTF-8 `/` 分隔的规范相对路径;拒绝反斜杠、盘符、冒号/NTFS ADS、NUL、`.`/`..` 和大小写折叠后的重复输出路径。 +- 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。 +- 拒绝符号链接、设备/管道等特殊文件和加密条目。 +- 原型默认上限:10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。T-302 按真实包体分布复核后再冻结。 +- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。 + ### 2.4 安装记录 installed-app.json(本地) 记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps//`。 diff --git a/docs/current-state.md b/docs/current-state.md index c7b9cd2..d0074ba 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,24 +13,24 @@ ## 当前快照 - 日期:2026-07-16 -- 阶段:M2 进行中(Phase 1 的 T-101 清单验签与缓存回退原型已完成) +- 阶段:M2 进行中(Phase 1 的 T-101 清单验签与 T-102 ZIP 安全解压原型已完成) - 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入 -- 生产代码:core 已有状态/事件 runtime 与 Catalog Ed25519 验签、受限规范 JSON、验证后缓存回退原型;modern/win7 均可打开最小 AppShell -- 测试:core 覆盖状态/事件、Catalog 篡改/伪造/歧义输入、缓存回退和文件缓存恢复;两个 app 覆盖 AppShell 与平台 stub -- 数据:`testdata/catalog/` 已有公开虚构的合法 payload、篡改 payload 与伪造签名样例 +- 生产代码:core 已有状态/事件、Catalog 验签/缓存原型与 ZIP 两阶段安全解压器;modern/win7 均可打开最小 AppShell +- 测试:core 覆盖 Catalog 攻击/缓存与 ZIP 路径、类型、资源上限、entrypoint、CRC 失败清理;两个 app 覆盖 AppShell 与平台 stub +- 数据:`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;下一步按路线图落成并领取 T-102 +- 当前 blocker:无;下一步按路线图落成并领取 T-103 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0 四项与 T-101 已完成;T-102 待按路线图落成 | +| `docs/tasks/` | 已有 | Phase 0 四项与 T-101/T-102 已完成;T-103 待按路线图落成 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容共享模块;已有状态/事件与 Catalog 验签/缓存原型 | +| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、Catalog 验签/缓存和 ZIP 安全解压原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell | | `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell | | `schemas/` | 待建 | 协议 JSON Schema(T-201) | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`。 - 正在进行:无。 -- 下一个可领取任务:按路线图落成并领取 `T-102 ZIP 安全解压原型`。 +- 下一个可领取任务:按路线图落成并领取 `T-103 staging/backup 原子切换与回滚原型`。 ## 当前可运行内容 diff --git a/docs/tasks/T-102.md b/docs/tasks/T-102.md new file mode 100644 index 0000000..a23c70a --- /dev/null +++ b/docs/tasks/T-102.md @@ -0,0 +1,64 @@ +--- +id: T-102 +title: ZIP 软件包安全解压原型 +phase: 1 +deps: [T-002] +status: DONE +created: 2026-07-16 +issue: null +context_ref: 0d6ed05d7e69f4a8e2a20e93051e1fe48a868940 +claim_branch: null +work_branch: agent/codex/T-102 +write_paths: + - docs/tasks/T-102.md + - core/installer/ + - testdata/zip/ + - docs/api.md + - docs/04-architecture.md + - docs/current-state.md +--- + +## 问题 / 背景 + +软件包最终会把远端 ZIP 内容写入 staging 并切换为可执行程序。仅依赖 `archive/zip` 默认行为无法防止路径穿越、Windows 盘符/ADS、符号链接、重复覆盖和 zip bomb,必须在写磁盘前完整预检。 + +## 方案 + +1. 在 `core/installer` 建立 `Extractor`,调用者显式提供条目数、总解压体积和压缩比限制。 +2. 两阶段处理:先验证整个中央目录和 entrypoint,全部通过后才创建新的 staging 目录并解压 `payload/` 内容。 +3. 拒绝绝对路径、盘符/冒号、反斜杠、空/NUL/非规范路径、`..`、大小写折叠重复路径、符号链接、特殊文件、加密条目和协议外顶层条目。 +4. 对每个条目及全包执行压缩比检查,实际复制时再次用剩余总量限制读取,失败清理本次创建的 staging。 +5. entrypoint 必须是 payload 内安全相对路径,并对应 ZIP 中的普通文件。 +6. 使用表驱动测试在运行时生成攻击 ZIP;`testdata/zip` 记录样例策略与限制结论。 + +## 验收要点 + +- 合法包只把 `payload/` 内容解压到新 staging,不复制 `app.json` / `files.json`。 +- 绝对路径、`../`、反斜杠穿越、盘符/ADS、符号链接、特殊文件和重复路径全部拒绝。 +- 条目数、总解压体积、单条/总体压缩比超限全部拒绝。 +- entrypoint 绝对/穿越/payload 外逃或不存在全部拒绝。 +- 任一预检/复制失败不留下可被误用的 staging 目录。 +- Go 1.20 core vet/test 和完整双目标闸门通过。 + +## 边界(不改什么) + +- 不实现 SHA-256、Catalog/package 身份比对、app.json/files.json Schema(T-302/T-201)。 +- 不执行任何包内脚本,不处理 prerequisites。 +- 不切换 current/backup(T-103)。 +- 默认限制是 Phase 1 原型值,T-302 正式整合时按真实包规模复核。 + +## 协作约束 + +未启用 Gitea;本任务在 `agent/codex/T-102` 分支串行执行。Extractor 文档明确要求调用者只传入已完成签名与 SHA-256 校验的 ZIP。 + +## 执行记录 + +- 2026-07-16:在 `core/installer` 建立 Limits/Extractor,采用完整预检后再创建 staging 的两阶段流程;只解压 `payload/`,不复制根部元数据。 +- 2026-07-16:路径规则拒绝无效 UTF-8、NUL、绝对/UNC、盘符/冒号/ADS、反斜杠、非规范路径与 `..`;大小写折叠后重复路径也拒绝,避免 Windows 覆盖歧义。 +- 2026-07-16:拒绝符号链接、特殊文件、加密条目、协议外顶层文件;要求 app.json 存在且 entrypoint 是 payload 内精确匹配的普通文件。 +- 2026-07-16:原型默认限制为 10,000 条目、4 GiB 总展开、单条/总体 200:1 压缩比;实际复制再次按剩余额度限流并核对 header 展开大小。 +- 2026-07-16:任一复制、CRC 或关闭错误会删除本次新建 staging;已有 destination 直接拒绝,不覆盖未知内容。 +- 2026-07-16:攻击 ZIP 由表驱动测试运行时生成,策略记录于 `testdata/zip/README.md`;标准库会先解析中央目录再暴露条目列表,如需抵御超大中央目录的预解析内存压力,T-302 应增加 ZIP 文件/中央目录预扫描限制。 +- 定向测试通过:合法 payload、绝对/盘符/ADS/`../`/反斜杠、加密、symlink、特殊文件、重复路径、协议外文件、条目数、总展开、压缩比、entrypoint 外逃/缺失、已有 staging 和 CRC 损坏清理。 +- 验证通过:Go 1.20.14 core vet/test。 +- 验证通过:`./scripts/verify_phase0.ps1`,包含 modern/win7 双目标构建与治理检查。 diff --git a/testdata/zip/README.md b/testdata/zip/README.md new file mode 100644 index 0000000..cb9d63e --- /dev/null +++ b/testdata/zip/README.md @@ -0,0 +1,11 @@ +# ZIP 攻击样例策略 + +T-102 的表驱动测试在运行时生成 ZIP,避免提交难审查的二进制文件。覆盖: + +- 绝对路径、盘符、NTFS ADS、`../`、反斜杠穿越。 +- 符号链接、特殊文件、大小写折叠重复路径、协议外顶层文件。 +- 条目数、总解压体积、压缩比上限。 +- entrypoint 绝对/穿越/重复 `payload/` 前缀/不存在。 +- 数据 CRC 损坏导致复制失败时清理 staging。 + +所有内容均为测试专用,不包含真实软件包或生产地址。