From 0f69fa330edb38a7d2ee4ae3ec98ad9f493e3483 Mon Sep 17 00:00:00 2001 From: ila Date: Sat, 18 Jul 2026 15:40:44 +0800 Subject: [PATCH] Preflight ZIP central directory metadata (T-612) --- core/installer/extractor.go | 46 ++-- core/installer/extractor_test.go | 96 ++++++-- core/installer/extractor_windows_test.go | 7 +- core/installer/limits.go | 33 ++- core/installer/zipscan.go | 240 ++++++++++++++++++ core/installer/zipscan_test.go | 300 +++++++++++++++++++++++ docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 9 +- docs/05-coding-rules.md | 1 + docs/06-tasks.md | 2 +- docs/api.md | 4 +- docs/current-state.md | 14 +- docs/review/phase1-security-review.md | 9 +- docs/tasks/T-612.md | 5 +- 14 files changed, 702 insertions(+), 68 deletions(-) create mode 100644 core/installer/zipscan.go create mode 100644 core/installer/zipscan_test.go diff --git a/core/installer/extractor.go b/core/installer/extractor.go index 35f33d0..6b29102 100644 --- a/core/installer/extractor.go +++ b/core/installer/extractor.go @@ -14,20 +14,23 @@ import ( ) 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") + 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") + ErrArchiveSizeMismatch = errors.New("ZIP archive size does not match expected package size") + ErrArchiveTooLarge = errors.New("ZIP archive size limit exceeded") + ErrCentralDirectoryTooLarge = errors.New("ZIP central directory size limit exceeded") + 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. @@ -56,18 +59,25 @@ func NewExtractor(limits Limits) (Extractor, error) { return Extractor{limits: limits}, nil } -// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks. +// ExtractFile requires expectedPackageSize from the verified Catalog package. +// The completed download file must have precisely that size before any ZIP data is parsed. func (extractor Extractor) ExtractFile( zipPath string, destination string, entrypoint string, + expectedPackageSize int64, ) (ExtractResult, error) { - archive, err := zip.OpenReader(zipPath) + file, size, err := extractor.openAndScanArchive(zipPath, expectedPackageSize) + if err != nil { + return ExtractResult{}, err + } + defer file.Close() + + archive, err := zip.NewReader(file, size) if err != nil { return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err) } - defer archive.Close() - return extractor.extract(&archive.Reader, destination, entrypoint) + return extractor.extract(archive, destination, entrypoint) } func (extractor Extractor) extract( diff --git a/core/installer/extractor_test.go b/core/installer/extractor_test.go index 6154697..8209dce 100644 --- a/core/installer/extractor_test.go +++ b/core/installer/extractor_test.go @@ -21,7 +21,12 @@ func TestExtractorExtractsPayloadOnly(t *testing.T) { destination := filepath.Join(t.TempDir(), "staging") extractor := mustExtractor(t, testLimits()) - result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe") + result, err := extractor.ExtractFile( + archivePath, + destination, + "bin/App.exe", + archiveSize(t, archivePath), + ) if err != nil { t.Fatalf("ExtractFile() error = %v", err) } @@ -216,9 +221,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { testZIPEntry{name: "payload/extra.txt", body: []byte("x")}), entrypoint: "App.exe", limits: Limits{ - MaxEntries: 2, - MaxUncompressedBytes: 1024, - MaxCompressionRatio: 100, + MaxEntries: 2, + MaxArchiveBytes: 16 * 1024, + MaxCentralDirectoryBytes: 1024, + MaxUncompressedBytes: 1024, + MaxCompressionRatio: 100, }, wantErr: ErrTooManyEntries, }, @@ -230,9 +237,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { }, entrypoint: "App.exe", limits: Limits{ - MaxEntries: 10, - MaxUncompressedBytes: 8, - MaxCompressionRatio: 100, + MaxEntries: 10, + MaxArchiveBytes: 16 * 1024, + MaxCentralDirectoryBytes: 1024, + MaxUncompressedBytes: 8, + MaxCompressionRatio: 100, }, wantErr: ErrExpandedTooLarge, }, @@ -248,9 +257,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { }, entrypoint: "App.exe", limits: Limits{ - MaxEntries: 10, - MaxUncompressedBytes: 8192, - MaxCompressionRatio: 2, + MaxEntries: 10, + MaxArchiveBytes: 16 * 1024, + MaxCentralDirectoryBytes: 1024, + MaxUncompressedBytes: 8192, + MaxCompressionRatio: 2, }, wantErr: ErrCompressionRatio, }, @@ -263,7 +274,12 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { destination := filepath.Join(root, "staging") extractor := mustExtractor(t, test.limits) - _, err := extractor.ExtractFile(archivePath, destination, test.entrypoint) + _, 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) } @@ -303,7 +319,12 @@ func TestExtractorRejectsInvalidEntrypoints(t *testing.T) { 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) + _, 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) } @@ -323,7 +344,12 @@ func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) { destination := filepath.Join(t.TempDir(), "staging") extractor := mustExtractor(t, testLimits()) - result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe") + result, err := extractor.ExtractFile( + archivePath, + destination, + "工具/解析器.exe", + archiveSize(t, archivePath), + ) if err != nil { t.Fatalf("ExtractFile() error = %v", err) } @@ -346,7 +372,12 @@ func TestExtractorRejectsExistingDestination(t *testing.T) { } extractor := mustExtractor(t, testLimits()) - _, err := extractor.ExtractFile(archivePath, destination, "App.exe") + _, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) if !errors.Is(err, ErrDestinationExists) { t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists) } @@ -361,7 +392,12 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) { destination := filepath.Join(t.TempDir(), "staging") extractor := mustExtractor(t, testLimits()) - _, err := extractor.ExtractFile(archivePath, destination, "App.exe") + _, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) if !errors.Is(err, ErrArchiveCorrupt) { t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt) } @@ -380,12 +416,23 @@ type testZIPEntry struct { func testLimits() Limits { return Limits{ - MaxEntries: 20, - MaxUncompressedBytes: 16 * 1024, - MaxCompressionRatio: 100, + 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) @@ -483,11 +530,14 @@ func TestDefaultLimitsAreValid(t *testing.T) { 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)}, + {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 { diff --git a/core/installer/extractor_windows_test.go b/core/installer/extractor_windows_test.go index 3512a91..cb08cfd 100644 --- a/core/installer/extractor_windows_test.go +++ b/core/installer/extractor_windows_test.go @@ -19,7 +19,12 @@ func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T) destination := filepath.Join(root, "staging") extractor := mustExtractor(t, testLimits()) - _, err := extractor.ExtractFile(archivePath, destination, "App.exe") + _, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) if !errors.Is(err, ErrPathEscape) { t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape) } diff --git a/core/installer/limits.go b/core/installer/limits.go index b0ca1a9..0b132fd 100644 --- a/core/installer/limits.go +++ b/core/installer/limits.go @@ -9,23 +9,29 @@ import ( var ErrInvalidLimits = errors.New("invalid ZIP extraction limits") const ( - DefaultMaxEntries = 10_000 - DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024) - DefaultMaxCompressionRatio = 200.0 + DefaultMaxEntries = 10_000 + DefaultMaxArchiveBytes = int64(4 * 1024 * 1024 * 1024) + DefaultMaxCentralDirectoryBytes = int64(64 * 1024 * 1024) + 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 + MaxEntries int + MaxArchiveBytes int64 + MaxCentralDirectoryBytes int64 + MaxUncompressedBytes int64 + MaxCompressionRatio float64 } func DefaultLimits() Limits { return Limits{ - MaxEntries: DefaultMaxEntries, - MaxUncompressedBytes: DefaultMaxUncompressedBytes, - MaxCompressionRatio: DefaultMaxCompressionRatio, + MaxEntries: DefaultMaxEntries, + MaxArchiveBytes: DefaultMaxArchiveBytes, + MaxCentralDirectoryBytes: DefaultMaxCentralDirectoryBytes, + MaxUncompressedBytes: DefaultMaxUncompressedBytes, + MaxCompressionRatio: DefaultMaxCompressionRatio, } } @@ -33,6 +39,15 @@ func (limits Limits) validate() error { if limits.MaxEntries <= 0 { return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits) } + if limits.MaxArchiveBytes <= 0 { + return fmt.Errorf("%w: MaxArchiveBytes must be positive", ErrInvalidLimits) + } + if limits.MaxCentralDirectoryBytes <= 0 { + return fmt.Errorf("%w: MaxCentralDirectoryBytes must be positive", ErrInvalidLimits) + } + if limits.MaxCentralDirectoryBytes > limits.MaxArchiveBytes { + return fmt.Errorf("%w: MaxCentralDirectoryBytes exceeds MaxArchiveBytes", ErrInvalidLimits) + } if limits.MaxUncompressedBytes <= 0 { return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits) } diff --git a/core/installer/zipscan.go b/core/installer/zipscan.go new file mode 100644 index 0000000..1841652 --- /dev/null +++ b/core/installer/zipscan.go @@ -0,0 +1,240 @@ +package installer + +import ( + "encoding/binary" + "fmt" + "io" + "os" +) + +const ( + endOfCentralDirectorySignature = 0x06054b50 + zip64EndSignature = 0x06064b50 + zip64LocatorSignature = 0x07064b50 + + endOfCentralDirectoryLength = 22 + zip64EndLength = 56 + zip64LocatorLength = 20 + maxZIPCommentLength = 1<<16 - 1 + maxEOCDSearchLength = endOfCentralDirectoryLength + maxZIPCommentLength + maxInt64 = 1<<63 - 1 +) + +type endOfCentralDirectory struct { + offset int64 + diskNumber uint32 + centralDirectoryDisk uint32 + entriesOnThisDisk uint64 + centralDirectoryEntries uint64 + centralDirectorySize uint64 + centralDirectoryOffset uint64 +} + +func (extractor Extractor) openAndScanArchive( + zipPath string, + expectedPackageSize int64, +) (*os.File, int64, error) { + if err := extractor.limits.validate(); err != nil { + return nil, 0, err + } + if expectedPackageSize <= 0 { + return nil, 0, fmt.Errorf("%w: expected size must be positive", ErrArchiveSizeMismatch) + } + + pathInfo, err := os.Lstat(zipPath) + if err != nil { + return nil, 0, fmt.Errorf("%w: inspect package: %v", ErrInvalidArchive, err) + } + if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() { + return nil, 0, fmt.Errorf("%w: package is not a regular file", ErrInvalidArchive) + } + + file, err := os.Open(zipPath) + if err != nil { + return nil, 0, fmt.Errorf("%w: open package: %v", ErrInvalidArchive, err) + } + closeWithError := func(err error) (*os.File, int64, error) { + _ = file.Close() + return nil, 0, err + } + info, err := file.Stat() + if err != nil { + return closeWithError(fmt.Errorf("%w: stat package: %v", ErrInvalidArchive, err)) + } + if !info.Mode().IsRegular() { + return closeWithError(fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive)) + } + size := info.Size() + if size != expectedPackageSize { + return closeWithError(fmt.Errorf( + "%w: got %d, expected %d", + ErrArchiveSizeMismatch, + size, + expectedPackageSize, + )) + } + if size > extractor.limits.MaxArchiveBytes { + return closeWithError(fmt.Errorf( + "%w: got %d, limit %d", + ErrArchiveTooLarge, + size, + extractor.limits.MaxArchiveBytes, + )) + } + if err := scanCentralDirectory(file, size, extractor.limits); err != nil { + return closeWithError(err) + } + return file, size, nil +} + +func scanCentralDirectory(file *os.File, size int64, limits Limits) error { + end, err := findEndOfCentralDirectory(file, size) + if err != nil { + return err + } + if requiresZIP64(end) { + if err := readZIP64EndOfCentralDirectory(file, &end); err != nil { + return err + } + } + if end.diskNumber != 0 || end.centralDirectoryDisk != 0 || + end.entriesOnThisDisk != end.centralDirectoryEntries { + return fmt.Errorf("%w: multi-disk archives are unsupported", ErrInvalidArchive) + } + if end.centralDirectoryEntries > uint64(limits.MaxEntries) { + return fmt.Errorf( + "%w: got %d, limit %d", + ErrTooManyEntries, + end.centralDirectoryEntries, + limits.MaxEntries, + ) + } + if end.centralDirectorySize > uint64(limits.MaxCentralDirectoryBytes) { + return fmt.Errorf( + "%w: got %d, limit %d", + ErrCentralDirectoryTooLarge, + end.centralDirectorySize, + limits.MaxCentralDirectoryBytes, + ) + } + if end.centralDirectorySize > uint64(maxInt64) || + end.centralDirectoryOffset > uint64(maxInt64) { + return fmt.Errorf("%w: central directory exceeds int64", ErrInvalidArchive) + } + + centralDirectorySize := int64(end.centralDirectorySize) + centralDirectoryOffset := int64(end.centralDirectoryOffset) + if centralDirectorySize > end.offset { + return fmt.Errorf("%w: central directory exceeds end record", ErrInvalidArchive) + } + centralDirectoryStart := end.offset - centralDirectorySize + if centralDirectoryOffset > centralDirectoryStart { + return fmt.Errorf("%w: central directory offset is outside archive", ErrInvalidArchive) + } + baseOffset := centralDirectoryStart - centralDirectoryOffset + if baseOffset < 0 || centralDirectoryStart > size || + centralDirectoryStart+centralDirectorySize != end.offset { + return fmt.Errorf("%w: central directory bounds are inconsistent", ErrInvalidArchive) + } + return nil +} + +func findEndOfCentralDirectory(file *os.File, size int64) (endOfCentralDirectory, error) { + if size < endOfCentralDirectoryLength { + return endOfCentralDirectory{}, fmt.Errorf("%w: archive is shorter than EOCD", ErrInvalidArchive) + } + readLength := int64(maxEOCDSearchLength) + if readLength > size { + readLength = size + } + buffer := make([]byte, int(readLength)) + readOffset := size - readLength + if err := readAtExactly(file, buffer, readOffset); err != nil { + return endOfCentralDirectory{}, err + } + + for offset := len(buffer) - endOfCentralDirectoryLength; offset >= 0; offset-- { + if binary.LittleEndian.Uint32(buffer[offset:]) != endOfCentralDirectorySignature { + continue + } + commentLength := int(binary.LittleEndian.Uint16(buffer[offset+20:])) + if offset+endOfCentralDirectoryLength+commentLength != len(buffer) { + continue + } + return endOfCentralDirectory{ + offset: readOffset + int64(offset), + diskNumber: uint32(binary.LittleEndian.Uint16(buffer[offset+4:])), + centralDirectoryDisk: uint32(binary.LittleEndian.Uint16(buffer[offset+6:])), + entriesOnThisDisk: uint64(binary.LittleEndian.Uint16(buffer[offset+8:])), + centralDirectoryEntries: uint64(binary.LittleEndian.Uint16(buffer[offset+10:])), + centralDirectorySize: uint64(binary.LittleEndian.Uint32(buffer[offset+12:])), + centralDirectoryOffset: uint64(binary.LittleEndian.Uint32(buffer[offset+16:])), + }, nil + } + return endOfCentralDirectory{}, fmt.Errorf("%w: EOCD is missing or malformed", ErrInvalidArchive) +} + +func requiresZIP64(end endOfCentralDirectory) bool { + return end.entriesOnThisDisk == 0xffff || + end.centralDirectoryEntries == 0xffff || + end.centralDirectorySize == 0xffffffff || + end.centralDirectoryOffset == 0xffffffff +} + +func readZIP64EndOfCentralDirectory(file *os.File, end *endOfCentralDirectory) error { + locatorOffset := end.offset - zip64LocatorLength + if locatorOffset < 0 { + return fmt.Errorf("%w: ZIP64 locator is missing", ErrInvalidArchive) + } + locator := make([]byte, zip64LocatorLength) + if err := readAtExactly(file, locator, locatorOffset); err != nil { + return err + } + if binary.LittleEndian.Uint32(locator) != zip64LocatorSignature || + binary.LittleEndian.Uint32(locator[4:]) != 0 || + binary.LittleEndian.Uint32(locator[16:]) != 1 { + return fmt.Errorf("%w: ZIP64 locator is invalid", ErrInvalidArchive) + } + zip64EndOffset, ok := uint64AsInt64(binary.LittleEndian.Uint64(locator[8:])) + if !ok || zip64EndOffset < 0 || zip64EndOffset > locatorOffset-zip64EndLength { + return fmt.Errorf("%w: ZIP64 end offset is invalid", ErrInvalidArchive) + } + zip64End := make([]byte, zip64EndLength) + if err := readAtExactly(file, zip64End, zip64EndOffset); err != nil { + return err + } + if binary.LittleEndian.Uint32(zip64End) != zip64EndSignature { + return fmt.Errorf("%w: ZIP64 end record is invalid", ErrInvalidArchive) + } + recordSize := binary.LittleEndian.Uint64(zip64End[4:]) + if recordSize < 44 || recordSize > uint64(locatorOffset-zip64EndOffset-12) { + return fmt.Errorf("%w: ZIP64 end record length is invalid", ErrInvalidArchive) + } + + end.offset = zip64EndOffset + end.diskNumber = binary.LittleEndian.Uint32(zip64End[16:]) + end.centralDirectoryDisk = binary.LittleEndian.Uint32(zip64End[20:]) + end.entriesOnThisDisk = binary.LittleEndian.Uint64(zip64End[24:]) + end.centralDirectoryEntries = binary.LittleEndian.Uint64(zip64End[32:]) + end.centralDirectorySize = binary.LittleEndian.Uint64(zip64End[40:]) + end.centralDirectoryOffset = binary.LittleEndian.Uint64(zip64End[48:]) + return nil +} + +func readAtExactly(file *os.File, buffer []byte, offset int64) error { + count, err := file.ReadAt(buffer, offset) + if err == nil && count == len(buffer) { + return nil + } + if err == nil { + err = io.ErrUnexpectedEOF + } + return fmt.Errorf("%w: read ZIP metadata: %v", ErrInvalidArchive, err) +} + +func uint64AsInt64(value uint64) (int64, bool) { + if value > uint64(maxInt64) { + return 0, false + } + return int64(value), true +} diff --git a/core/installer/zipscan_test.go b/core/installer/zipscan_test.go new file mode 100644 index 0000000..22a57fa --- /dev/null +++ b/core/installer/zipscan_test.go @@ -0,0 +1,300 @@ +package installer + +import ( + "encoding/binary" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestExtractorRejectsArchiveSizeBeforeZIPReader(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/App.exe", body: []byte("ok")}, + }) + size := archiveSize(t, archivePath) + + tests := []struct { + name string + limits Limits + expected int64 + wantErr error + }{ + { + name: "expected size mismatch", + limits: testLimits(), + expected: size + 1, + wantErr: ErrArchiveSizeMismatch, + }, + { + name: "expected size is unknown", + limits: testLimits(), + expected: 0, + wantErr: ErrArchiveSizeMismatch, + }, + { + name: "archive exceeds raw size limit", + limits: Limits{ + MaxEntries: 20, + MaxArchiveBytes: size - 1, + MaxCentralDirectoryBytes: size - 1, + MaxUncompressedBytes: 16 * 1024, + MaxCompressionRatio: 100, + }, + expected: size, + wantErr: ErrArchiveTooLarge, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, test.limits) + + _, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + test.expected, + ) + if !errors.Is(err, test.wantErr) { + t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr) + } + assertStagingAbsent(t, destination) + }) + } +} + +func TestExtractorRejectsCentralDirectoryMetadataBeforeZIPReader(t *testing.T) { + tests := []struct { + name string + mutate func(t *testing.T, data []byte, eocdOffset int) + want error + }{ + { + name: "declared entries exceed limit", + mutate: func(t *testing.T, data []byte, eocdOffset int) { + t.Helper() + binary.LittleEndian.PutUint16(data[eocdOffset+8:], 21) + binary.LittleEndian.PutUint16(data[eocdOffset+10:], 21) + }, + want: ErrTooManyEntries, + }, + { + name: "central directory exceeds limit", + mutate: func(t *testing.T, data []byte, eocdOffset int) { + t.Helper() + binary.LittleEndian.PutUint32(data[eocdOffset+12:], 1025) + }, + want: ErrCentralDirectoryTooLarge, + }, + { + name: "multi disk archive", + mutate: func(t *testing.T, data []byte, eocdOffset int) { + t.Helper() + binary.LittleEndian.PutUint16(data[eocdOffset+4:], 1) + }, + want: ErrInvalidArchive, + }, + { + name: "central directory offset is outside archive", + mutate: func(t *testing.T, data []byte, eocdOffset int) { + t.Helper() + binary.LittleEndian.PutUint32(data[eocdOffset+16:], 0xfffffffe) + }, + want: ErrInvalidArchive, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/App.exe", body: []byte("ok")}, + }) + data, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read archive: %v", err) + } + eocdOffset := len(data) - endOfCentralDirectoryLength + test.mutate(t, data, eocdOffset) + if err := os.WriteFile(archivePath, data, 0o600); err != nil { + t.Fatalf("write archive: %v", err) + } + + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + _, err = extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) + if !errors.Is(err, test.want) { + t.Fatalf("ExtractFile() error = %v, want %v", err, test.want) + } + assertStagingAbsent(t, destination) + }) + } +} + +func TestExtractorRejectsTruncatedEOCD(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/App.exe", body: []byte("ok")}, + }) + if err := os.Truncate(archivePath, archiveSize(t, archivePath)-1); err != nil { + t.Fatalf("truncate archive: %v", err) + } + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + + _, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) + if !errors.Is(err, ErrInvalidArchive) { + t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive) + } + assertStagingAbsent(t, destination) +} + +func TestExtractorRejectsMissingOrInvalidZIP64End(t *testing.T) { + tests := []struct { + name string + mutate func(data []byte) + }{ + { + name: "missing locator", + mutate: func(data []byte) { + eocdOffset := len(data) - endOfCentralDirectoryLength + data[eocdOffset-zip64LocatorLength] ^= 0xff + }, + }, + { + name: "invalid record length", + mutate: func(data []byte) { + eocdOffset := len(data) - endOfCentralDirectoryLength + zip64EndOffset := int(binary.LittleEndian.Uint64(data[eocdOffset-zip64LocatorLength+8:])) + binary.LittleEndian.PutUint64(data[zip64EndOffset+4:], 43) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + archivePath := writeZIP64TestZIP(t) + data, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read archive: %v", err) + } + test.mutate(data) + if err := os.WriteFile(archivePath, data, 0o600); err != nil { + t.Fatalf("write archive: %v", err) + } + + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + _, err = extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) + if !errors.Is(err, ErrInvalidArchive) { + t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive) + } + assertStagingAbsent(t, destination) + }) + } +} + +func TestExtractorAcceptsZIP64EndOfCentralDirectory(t *testing.T) { + archivePath := writeZIP64TestZIP(t) + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + + result, err := extractor.ExtractFile( + archivePath, + destination, + "App.exe", + archiveSize(t, archivePath), + ) + if err != nil { + t.Fatalf("ExtractFile() error = %v", err) + } + if result.Files != 1 { + t.Fatalf("Files = %d, want 1", result.Files) + } +} + +func TestExtractorRejectsNonRegularArchive(t *testing.T) { + directory := t.TempDir() + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + + _, err := extractor.ExtractFile(directory, destination, "App.exe", 1) + if !errors.Is(err, ErrInvalidArchive) { + t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive) + } + assertStagingAbsent(t, destination) +} + +func writeZIP64TestZIP(t *testing.T) string { + t.Helper() + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/App.exe", body: []byte("ok")}, + }) + data, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read ZIP: %v", err) + } + eocdOffset := len(data) - endOfCentralDirectoryLength + entries := binary.LittleEndian.Uint16(data[eocdOffset+10:]) + centralDirectorySize := binary.LittleEndian.Uint32(data[eocdOffset+12:]) + centralDirectoryOffset := binary.LittleEndian.Uint32(data[eocdOffset+16:]) + + zip64EndOffset := len(data) - endOfCentralDirectoryLength + zip64End := make([]byte, zip64EndLength) + binary.LittleEndian.PutUint32(zip64End, zip64EndSignature) + binary.LittleEndian.PutUint64(zip64End[4:], 44) + binary.LittleEndian.PutUint16(zip64End[12:], 45) + binary.LittleEndian.PutUint16(zip64End[14:], 45) + binary.LittleEndian.PutUint64(zip64End[24:], uint64(entries)) + binary.LittleEndian.PutUint64(zip64End[32:], uint64(entries)) + binary.LittleEndian.PutUint64(zip64End[40:], uint64(centralDirectorySize)) + binary.LittleEndian.PutUint64(zip64End[48:], uint64(centralDirectoryOffset)) + + locator := make([]byte, zip64LocatorLength) + binary.LittleEndian.PutUint32(locator, zip64LocatorSignature) + binary.LittleEndian.PutUint64(locator[8:], uint64(zip64EndOffset)) + binary.LittleEndian.PutUint32(locator[16:], 1) + + classicEnd := make([]byte, endOfCentralDirectoryLength) + binary.LittleEndian.PutUint32(classicEnd, endOfCentralDirectorySignature) + binary.LittleEndian.PutUint16(classicEnd[8:], 0xffff) + binary.LittleEndian.PutUint16(classicEnd[10:], 0xffff) + binary.LittleEndian.PutUint32(classicEnd[12:], 0xffffffff) + binary.LittleEndian.PutUint32(classicEnd[16:], 0xffffffff) + + zip64Data := make([]byte, 0, len(data)+zip64EndLength+zip64LocatorLength) + zip64Data = append(zip64Data, data[:eocdOffset]...) + zip64Data = append(zip64Data, zip64End...) + zip64Data = append(zip64Data, locator...) + zip64Data = append(zip64Data, classicEnd...) + if err := os.WriteFile(archivePath, zip64Data, 0o600); err != nil { + t.Fatalf("write ZIP64 archive: %v", err) + } + return archivePath +} + +func assertStagingAbsent(t *testing.T, destination string) { + t.Helper() + if _, err := os.Stat(destination); !os.IsNotExist(err) { + t.Fatalf("rejected archive left staging, stat error = %v", err) + } +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 535eccc..275f0ce 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-611`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分以及 unsafe cache 安全诊断/runbook 均已关闭;`T-612` 已落成为中央目录/EOCD 预扫描边界任务并是当前唯一待领取项,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-612`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook 以及 ZIP 中央目录/EOCD(含 ZIP64)预扫描均已关闭;下一步按 Phase 1 审核顺序落成断电耐久性整改,T-302 暂后置。 优先路径: @@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 已完成 T-606~T-611:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分和 unsafe cache 诊断/人工恢复指引;下一步执行 T-612 的 Phase 1 中央目录预扫描整改,再继续 T-302/T-303 与 Phase 4-6。 +5. 已完成 T-606~T-612:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引和 ZIP 中央目录/EOCD 预扫描;下一步串行处理 Phase 1 断电耐久性整改,再继续 T-302/T-303 与 Phase 4-6。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 3e6e4ff..46ed2ab 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -158,7 +158,7 @@ T-301 下载队列: - cancel 一旦先于 completion 取得线性化点,即使 body close/sync 报错也记录后继续精确清理;若 completion 先完成,后续 cancel 明确拒绝。known-total 完整 part 在同进程 resume/retry 与启动恢复中都直接 finalize,不发送 offset==total 的 Range。 - 写入句柄的文件身份贯穿 sync/close 与 rename 前后核对,防止活跃 `.part` 路径被替换后发布错误文件。崩溃恢复对账 metadata、part、final 三份事实:完整 part 可 finalize,已 rename 的 final 可补 completed metadata;缺 final 的 completed、part+final、超出 expected/unknown 上限均 fail closed。 - 事件投递失败通过 `OnObserverError` 显式报告,不改变 durable transfer 结果;application/UI 启动或重连后用 `Queue.Tasks()` 对账终态,避免 DownloadCompleted 等一次性通知丢失后永久停链。 -- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature。 +- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 不得把它们当信任根,仍需从已验签 Catalog 重新取得并核对身份/size/hash/signature,并把该 Catalog `size` 传给 `Extractor.ExtractFile` 对同一打开的普通完成文件重新对账。 ### 4.3 事件模型 @@ -170,15 +170,16 @@ T-301 下载队列: ```text 读取已签名 Catalog → 选择 OS/架构匹配的 Package → 下载到 downloads -→ 校验 size 与 SHA-256 → 安全读取 app.json → 比对 ID/版本/通道/系统/架构 -→ 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe +→ 校验 size 与 SHA-256 → 打开同一普通完成文件,使实际长度 = Catalog size +→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 安全读取 app.json +→ 比对 ID/版本/通道/系统/架构 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging → 校验 entry_exe → 确认目标软件已退出 → current 改名 backup → staging 原子切换为 current → 健康检查 → 成功延迟清理 backup / 失败恢复 backup ``` 必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 -Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与资源上限,再规划并确认所有 native 输出路径仍在 destination 内,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。 +Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md);T-302 仍需把已验签 Catalog、SHA-256、T-301 的完成文件和此 API 串成生产安装链,并按真实包分布复核限额。 Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 7c96f49..1ab6601 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -35,6 +35,7 @@ - 任何下载内容未通过 SHA-256 + 签名验证,**不得解压执行**;清单验签失败拒绝,不回退到未验证内容。 - ZIP 与软件包路径必须使用 `core/internal/safepath` 的共享 Windows 安全相对路径策略,拒绝绝对路径、`../`/dot-space 归一化穿越、首尾 ASCII 空格、尾随句点、DOS 设备名、Windows 禁止字符、符号链接逃逸和 entrypoint 指向 payload 外;native 输出路径还必须验证仍在 destination 内。Catalog/storage/app.json/files.json 不得各自复制一套路径规则。 +- 任意 `zip.Reader` 构造前,必须以同一普通文件句柄核对已验签 Catalog 的 exact package `size` 与完成下载实际长度,并有界预扫描 EOCD/ZIP64:原始包、中央目录字节数和声明条目数超过硬上限,跨盘/截断/边界不一致元数据一律拒绝;不得先扫描路径后按该路径重新打开,不得仅依赖 `len(archive.File)`。 - ZIP 解压必须保留文件数、展开体积和压缩比硬上限。 - 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。 - 程序更新不得触碰 `data/` 与 `licenses/`。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 26f8ba4..c188fb7 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -42,7 +42,7 @@ #### Phase 1 交叉审核加固 -Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605 与 T-612 关闭前不恢复 T-302;后续断电耐久和签名向量任务在前一整改完成并提交后再正式编号。 +Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605 与 T-612 已关闭;后续断电耐久和签名向量任务在前一整改完成并提交后再正式编号,T-302 继续后置。 | ID | 任务 | 依赖 | 验收要点 | | --- | --- | --- | --- | diff --git a/docs/api.md b/docs/api.md index e702abc..3a87835 100644 --- a/docs/api.md +++ b/docs/api.md @@ -153,7 +153,9 @@ T-102 Phase 1 原型进一步固定: - ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。 - 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。 - 拒绝符号链接、设备/管道等特殊文件和加密条目。 -- 原型默认上限:10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。T-302 按真实包体分布复核后再冻结。 +- `Extractor.ExtractFile` 必须接收已验签 Catalog `packages[arch].size` 作为 `expectedPackageSize`;同一已打开的普通 `.download` 文件的实际长度必须与它精确相等,否则在读取 ZIP 前拒绝。T-301 的 known-total 完成文件已经以该 Catalog size 限长,但 T-302 仍须重新取得并传入可信 Catalog 身份,不能信任可篡改的下载 metadata。 +- 构造 `zip.Reader` 前只读取文件尾部至多 65,557 字节以定位 EOCD,并按需读取固定的 ZIP64 locator/EOCD 记录;校验单磁盘、中央目录 offset/size/entries 的边界及 entries/中央目录大小硬上限。当前默认上限为:原始包 4 GiB、中央目录 64 MiB、10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。大小不一致、原始包超限、中央目录超限、声明条目超限分别保留 `ErrArchiveSizeMismatch`、`ErrArchiveTooLarge`、`ErrCentralDirectoryTooLarge`、`ErrTooManyEntries` 错误链;格式、截断、跨盘或不一致 ZIP64 归入 `ErrInvalidArchive`。 +- 预扫描通过后使用**同一文件句柄**和已核对的长度创建 `zip.NewReader`;完整中央目录/路径/entrypoint/类型/CRC/展开量预检仍是第二道防线。合法 ZIP64 被支持,不因 32 位 EOCD 哨兵值误拒绝。T-302 按真实包体分布复核上述暂定限额后再冻结。 - entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。 - 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。 diff --git a/docs/current-state.md b/docs/current-state.md index e91c7ec..8555732 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,22 +13,22 @@ ## 当前快照 - 日期:2026-07-18 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-611 已完成,T-612 已落成为唯一 TODO,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-612 已完成,T-302 继续暂后置 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 -- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 -- 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 +- 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./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-611 已闭合 unsafe cache 诊断且自动 quarantine 仍需独立威胁模型。`T-612` 已落成,将在 ZIP reader 构造前增加 Catalog size、完成文件长度与中央目录/EOCD(含 ZIP64)预扫描边界;T-302 继续后置到该阻断项关闭 +- 当前 blocker:无;T-612 已闭合 ZIP reader 前的 Catalog size/完成文件长度/中央目录 EOCD(含 ZIP64)预扫描边界。下一步按 `docs/review/phase1-security-review.md` 最终处理顺序第 3 项落成断电耐久性整改(预计 T-613);T-302 继续后置到该阻断项关闭 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-611 已完成;T-612 是当前唯一 TODO 的 Phase 1 中央目录/EOCD 预扫描整改,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-612 已完成;下一项 Phase 1 断电耐久性整改尚未编号,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-611`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-612`。 - 正在进行:无。 -- 下一个可领取任务:`T-612` — 在 ZIP reader 构造前增加已验签 Catalog size、完成文件长度和中央目录/EOCD(含 ZIP64)预扫描边界。 +- 下一个可领取任务:暂无已落成 TODO;下一步按 Phase 1 最终处理顺序第 3 项落成断电耐久性整改(预计 `T-613`)。 ## 当前可运行内容 diff --git a/docs/review/phase1-security-review.md b/docs/review/phase1-security-review.md index 7835085..e11038c 100644 --- a/docs/review/phase1-security-review.md +++ b/docs/review/phase1-security-review.md @@ -200,7 +200,7 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题 ### 最终处理顺序(定稿) 1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。 -2. **[阻断 T-302]** `zip.OpenReader` 前增加包大小与中央目录/EOCD 预扫描边界;与 Catalog `size`、下载完成长度三者一致,不只依赖 `len(archive.File)`。 +2. **[已关闭 · T-612]** 已在构造 `zip.Reader` 前增加同句柄包大小与中央目录/EOCD(含 ZIP64)预扫描边界;Extractor 强制接收 expected Catalog `size`,核对打开文件长度并限制原始包、中央目录与声明条目数,不只依赖 `len(archive.File)`。T-302 仍须把已验签 Catalog、完成下载文件和 SHA-256 编排为真实安装调用链。 3. **[发布前必做]** 完整断电耐久策略:payload 文件 Sync → staging 目录元数据 → journal 顺序点 → 每次 rename 后父目录顺序点 → committed 耐久后才删 backup/journal;评估 Windows 目录句柄 `FlushFileBuffers`/写穿方案;T-302/T-601 用 VM/真机断电注入验证。 4. **[协议冻结前]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。 5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。 @@ -208,3 +208,10 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题 7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。 > 裁定:Codex 二次复核成立且纠正了原审核的绝对化表述与一处过松建议;M2 调整为"有条件成立"(T-101/T-103 原型目标成立;T-102 须先关闭 Windows 路径语义缺口才能作为正式安装链路可信基线)。上述顺序为双方交叉复核后的共识。 + +### T-612 完成记录(2026-07-18) + +- `Extractor.ExtractFile` 改为显式接收 `expectedPackageSize`,先打开同一普通文件,以其真实长度核对 Catalog 值和 4 GiB 原始包上限,再以该句柄构造 `zip.NewReader`;没有“预扫 path 后重开 path”的替换窗口。 +- 新的 EOCD 预扫描只读取最多 65,557 字节尾部和固定 ZIP64 locator/end 记录,拒绝跨盘、截断、offset/size 溢出或不一致结构,在标准库解析中央目录前限制 64 MiB 中央目录及 10,000 个声明条目。现有完整 `preflight` 保留为第二道 ZIP 语义和展开数据防线。 +- installer 回归覆盖 size 不一致、原始包超限、经典 EOCD 条目/目录伪造、跨盘/截断、ZIP64 有效和损坏 locator/end、非普通输入与 staging 未创建;`GOWORK=off go vet ./installer` 和 `go test -count=10 ./installer` 均通过。 +- 关闭的是 Extractor 边界,不是 T-302 生产安装整合或真实下载信任链;下一项断电耐久性整改仍独立阻断 T-302。 diff --git a/docs/tasks/T-612.md b/docs/tasks/T-612.md index 26288d1..d6ad346 100644 --- a/docs/tasks/T-612.md +++ b/docs/tasks/T-612.md @@ -3,7 +3,7 @@ id: T-612 title: 在 ZIP 打开前限制包大小与中央目录元数据 phase: 1 deps: [T-605] -status: TODO +status: DONE created: 2026-07-18 issue: null context_ref: 320b83d929fde7d51fc5bb3e9fbdaac7beb5101e @@ -69,3 +69,6 @@ T-301 已把 known-total 下载限定为精确长度,并在 final `.download` - 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 2 项落成任务;现有全局最大任务为 T-611,因此取 T-612,依赖已完成的 T-605。 - 2026-07-18:代码图确认 `ExtractFile` 直接调用 `zip.OpenReader`,随后才在 `preflight` 用 `len(archive.File)` 检查条目数;其调用方目前均为 installer 测试。T-301 已对 known-total 下载以已验签 Catalog size 严格限长并发布普通 final 文件,但尚无生产安装编排。因此本任务通过显式 `expectedPackageSize` 和同句柄预扫描建立正确接口,不宣称已完成 T-302 接线。 - 2026-07-18:默认 `MaxArchiveBytes` 将与 downloader `DefaultMaxUnknownBytes` 的 4 GiB 保持一致;中央目录单独限额及 ZIP64 兼容性必须以测试和文档冻结,真实包采样后的阈值复核仍留给 T-302。 +- 2026-07-18:实现 `MaxArchiveBytes=4 GiB`、`MaxCentralDirectoryBytes=64 MiB` 和对应配置校验;`ExtractFile` 现在强制接收 expected Catalog size,打开同一普通文件后先核对大小并执行有界 EOCD/ZIP64 扫描,再用该句柄构造 `zip.NewReader`。声明 entries/central size 在标准库解析前受限,跨盘、截断、越界与不一致 ZIP64 结构 fail closed,既有完整 `preflight` 保留。 +- 2026-07-18:installer 回归新增 size 不一致、原始包超限、经典 EOCD 条目/中央目录伪造、跨盘、越界、截断、ZIP64 成功/损坏及非普通输入矩阵,全部确认拒绝时不创建 staging;既有路径、CRC、展开量、压缩比与 destination 清理回归继续通过。 +- 2026-07-18:验证通过:`GOWORK=off go vet ./installer`、`GOWORK=off go test -count=10 ./installer`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与提交前 `git diff --check`;完整 Phase 0 闸门同时复跑 core 全包、modern/Win7 UI 测试与双目标构建。