Prototype secure ZIP extraction (T-102)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
|
||||||
|
|||||||
@@ -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>/`。
|
||||||
|
|||||||
@@ -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 原子切换与回滚原型`。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -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 双目标构建与治理检查。
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# ZIP 攻击样例策略
|
||||||
|
|
||||||
|
T-102 的表驱动测试在运行时生成 ZIP,避免提交难审查的二进制文件。覆盖:
|
||||||
|
|
||||||
|
- 绝对路径、盘符、NTFS ADS、`../`、反斜杠穿越。
|
||||||
|
- 符号链接、特殊文件、大小写折叠重复路径、协议外顶层文件。
|
||||||
|
- 条目数、总解压体积、压缩比上限。
|
||||||
|
- entrypoint 绝对/穿越/重复 `payload/` 前缀/不存在。
|
||||||
|
- 数据 CRC 损坏导致复制失败时清理 staging。
|
||||||
|
|
||||||
|
所有内容均为测试专用,不包含真实软件包或生产地址。
|
||||||
Reference in New Issue
Block a user