Prototype secure ZIP extraction (T-102)

This commit is contained in:
ila
2026-07-16 16:22:45 +08:00
parent 0d6ed05d7e
commit 0ffeff63e1
8 changed files with 890 additions and 9 deletions
+359
View File
@@ -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
}
+392
View File
@@ -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)
}
}
}
+45
View File
@@ -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
}
+2
View File
@@ -147,6 +147,8 @@ soft_quay/
必须防止:绝对路径、`../` 穿越、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 必须防止:绝对路径、`../` 穿越、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、路径、类型、重复项、entrypoint 与资源上限,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。
盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。
授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。 授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
+8
View File
@@ -119,6 +119,14 @@ json-parser_1.4.2_windows_amd64.zip
绝对路径;`../` 穿越;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。 绝对路径;`../` 穿越;符号链接/重解析点逃出 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(本地) ### 2.4 安装记录 installed-app.json(本地)
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。 记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
+9 -9
View File
@@ -13,24 +13,24 @@
## 当前快照 ## 当前快照
- 日期:2026-07-16 - 日期: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 已实际接入 - 技术栈:根 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 验签/缓存原型与 ZIP 两阶段安全解压器;modern/win7 均可打开最小 AppShell
- 测试:core 覆盖状态/事件、Catalog 篡改/伪造/歧义输入、缓存回退和文件缓存恢复;两个 app 覆盖 AppShell 与平台 stub - 测试:core 覆盖 Catalog 攻击/缓存与 ZIP 路径、类型、资源上限、entrypoint、CRC 失败清理;两个 app 覆盖 AppShell 与平台 stub
- 数据:`testdata/catalog/` 已有公开虚构的合法 payload、篡改 payload 与伪造签名样例 - 数据:`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:无;下一步按路线图落成并领取 T-102 - 当前 blocker:无;下一步按路线图落成并领取 T-103
## 当前目录要点 ## 当前目录要点
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) | | `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 双平台验证入口 | | `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-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell |
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell | | `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell |
| `schemas/` | 待建 | 协议 JSON Schema(T-201) | | `schemas/` | 待建 | 协议 JSON Schema(T-201) |
@@ -40,9 +40,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: 任务状态以 `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 原子切换与回滚原型`。
## 当前可运行内容 ## 当前可运行内容
+64
View File
@@ -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 双目标构建与治理检查。
+11
View File
@@ -0,0 +1,11 @@
# ZIP 攻击样例策略
T-102 的表驱动测试在运行时生成 ZIP,避免提交难审查的二进制文件。覆盖:
- 绝对路径、盘符、NTFS ADS、`../`、反斜杠穿越。
- 符号链接、特殊文件、大小写折叠重复路径、协议外顶层文件。
- 条目数、总解压体积、压缩比上限。
- entrypoint 绝对/穿越/重复 `payload/` 前缀/不存在。
- 数据 CRC 损坏导致复制失败时清理 staging。
所有内容均为测试专用,不包含真实软件包或生产地址。