From 6fd19d0f43ad7e61c6b7df6607e17e30a8171710 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 23:56:19 +0800 Subject: [PATCH] Harden Windows package paths (T-605) --- core/catalog/parser.go | 22 +-- core/catalog/parser_test.go | 39 ++++++ core/installer/extractor.go | 119 +++++++++------- core/installer/extractor_test.go | 108 ++++++++++++++- core/installer/extractor_windows_test.go | 32 +++++ core/internal/safepath/path.go | 167 +++++++++++++++++++++++ core/internal/safepath/path_test.go | 138 +++++++++++++++++++ core/storage/installed_app.go | 22 +-- core/storage/installed_app_test.go | 52 +++++++ docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 8 +- docs/05-coding-rules.md | 3 +- docs/api.md | 10 +- docs/current-state.md | 18 +-- docs/tasks/T-605.md | 17 ++- schemas/app.schema.json | 3 +- schemas/installed-app.schema.json | 3 +- schemas/manifest.schema.json | 3 +- testdata/zip/README.md | 4 +- 19 files changed, 664 insertions(+), 108 deletions(-) create mode 100644 core/installer/extractor_windows_test.go create mode 100644 core/internal/safepath/path.go create mode 100644 core/internal/safepath/path_test.go diff --git a/core/catalog/parser.go b/core/catalog/parser.go index 78ff3dd..cc05b21 100644 --- a/core/catalog/parser.go +++ b/core/catalog/parser.go @@ -9,12 +9,12 @@ import ( "fmt" "io" "net/url" - "path" "regexp" "strings" "time" "softbox.local/core/domain" + "softbox.local/core/internal/safepath" ) var ( @@ -163,8 +163,12 @@ func validateApp(app App) error { if len(app.Architectures) == 0 { return invalidField("architectures", "must not be empty") } - if !validSafeRelativePath(app.EntryEXE) { - return invalidField("entry_exe", "must be a safe relative path") + if err := safepath.ValidateRelative(app.EntryEXE); err != nil { + return invalidField( + "entry_exe", + "must be a safe Windows relative path: %v", + err, + ) } if len(app.Packages) == 0 { return invalidField("packages", "must not be empty") @@ -261,18 +265,6 @@ func validateHTTPSURL(value string) error { return nil } -func validSafeRelativePath(value string) bool { - if value == "" || strings.Contains(value, `\`) || strings.Contains(value, ":") { - return false - } - cleaned := path.Clean(value) - return cleaned == value && - cleaned != "." && - !strings.HasPrefix(cleaned, "/") && - cleaned != ".." && - !strings.HasPrefix(cleaned, "../") -} - func invalidField(field, format string, values ...any) error { return fmt.Errorf( "%w: %s: %s", diff --git a/core/catalog/parser_test.go b/core/catalog/parser_test.go index 3f97e79..49b87d1 100644 --- a/core/catalog/parser_test.go +++ b/core/catalog/parser_test.go @@ -70,6 +70,45 @@ func TestParserRejectsDuplicateAppIDAndPackageMismatch(t *testing.T) { } } +func TestParserRejectsUnsafeWindowsEntrypoints(t *testing.T) { + for _, entrypoint := range []string{ + ".. /escape.exe", + "bin./App.exe", + "App.exe.", + "App.exe ", + " App.exe", + "NUL", + "con.txt", + "bad?.exe", + `bin\App.exe`, + } { + t.Run(entrypoint, func(t *testing.T) { + manifest := validManifestForTest() + manifest.Apps[0].EntryEXE = entrypoint + _, err := parseSignedManifestForTest(t, manifest, ChannelModern) + if !errors.Is(err, ErrInvalidManifest) { + t.Fatalf("Parse() error = %v, want %v", err, ErrInvalidManifest) + } + }) + } +} + +func TestParserAcceptsUnicodeNestedEntrypoint(t *testing.T) { + manifest := validManifestForTest() + manifest.Apps[0].EntryEXE = "工具/解析器.exe" + parsed, err := parseSignedManifestForTest(t, manifest, ChannelModern) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if parsed.Apps[0].EntryEXE != manifest.Apps[0].EntryEXE { + t.Fatalf( + "EntryEXE = %q, want %q", + parsed.Apps[0].EntryEXE, + manifest.Apps[0].EntryEXE, + ) + } +} + func signedFixture(t *testing.T, name string) (Verifier, []byte) { t.Helper() publicKey, privateKey := catalogTestKey() diff --git a/core/installer/extractor.go b/core/installer/extractor.go index d61e4fc..35f33d0 100644 --- a/core/installer/extractor.go +++ b/core/installer/extractor.go @@ -7,10 +7,10 @@ import ( "io" "math" "os" - "path" "path/filepath" "strings" - "unicode/utf8" + + "softbox.local/core/internal/safepath" ) var ( @@ -45,6 +45,7 @@ type plannedEntry struct { file *zip.File archivePath string outputPath string + targetPath string directory bool } @@ -85,11 +86,19 @@ func (extractor Extractor) extract( if err != nil { return ExtractResult{}, err } + destinationRoot, entrypointPath, err := planOutputPaths( + destination, + normalizedEntrypoint, + plan, + ) + if err != nil { + return ExtractResult{}, err + } - if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + if err := os.MkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging parent: %w", err) } - if err := os.Mkdir(destination, 0o700); err != nil { + if err := os.Mkdir(destinationRoot, 0o700); err != nil { if os.IsExist(err) { return ExtractResult{}, ErrDestinationExists } @@ -98,23 +107,22 @@ func (extractor Extractor) extract( complete := false defer func() { if !complete { - _ = os.RemoveAll(destination) + _ = os.RemoveAll(destinationRoot) } }() 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 { + if err := os.MkdirAll(entry.targetPath, 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging directory: %w", err) } continue } - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + if err := os.MkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err) } @@ -126,7 +134,11 @@ func (extractor Extractor) extract( if entry.file.Mode().Perm()&0o111 != 0 { mode = 0o700 } - output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + output, err := os.OpenFile( + entry.targetPath, + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + mode, + ) if err != nil { source.Close() return ExtractResult{}, fmt.Errorf("create staging file: %w", err) @@ -166,14 +178,52 @@ func (extractor Extractor) extract( } result.Bytes = written - result.EntrypointPath = filepath.Join( - destination, - filepath.FromSlash(normalizedEntrypoint), - ) + result.EntrypointPath = entrypointPath complete = true return result, nil } +func planOutputPaths( + destination string, + entrypoint string, + plan []plannedEntry, +) (string, string, error) { + if destination == "" { + return "", "", fmt.Errorf("%w: staging destination is empty", ErrPathEscape) + } + destinationRoot, err := filepath.Abs(destination) + if err != nil { + return "", "", fmt.Errorf("%w: resolve staging destination: %v", ErrPathEscape, err) + } + destinationRoot = filepath.Clean(destinationRoot) + for index := range plan { + if plan[index].outputPath == "" { + plan[index].targetPath = destinationRoot + continue + } + target, err := safepath.JoinUnder(destinationRoot, plan[index].outputPath) + if err != nil { + return "", "", fmt.Errorf( + "%w: output %q: %v", + ErrPathEscape, + plan[index].outputPath, + err, + ) + } + plan[index].targetPath = target + } + entrypointPath, err := safepath.JoinUnder(destinationRoot, entrypoint) + if err != nil { + return "", "", fmt.Errorf( + "%w: entrypoint %q: %v", + ErrEntrypointInvalid, + entrypoint, + err, + ) + } + return destinationRoot, entrypointPath, nil +} + func (extractor Extractor) preflight( archive *zip.Reader, entrypoint string, @@ -200,7 +250,7 @@ func (extractor Extractor) preflight( if err != nil { return nil, err } - folded := strings.ToLower(normalized) + folded := safepath.CollisionKey(normalized) if previous, exists := seenPaths[folded]; exists { return nil, fmt.Errorf( "%w: %q conflicts with %q", @@ -305,47 +355,22 @@ func validateArchiveEntry(file *zip.File) (string, bool, error) { } 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) + if err := safepath.ValidateRelative(trimmed); err != nil { + return "", false, fmt.Errorf("%w: %q: %v", ErrPathEscape, name, err) } - cleaned := path.Clean(trimmed) - if cleaned != trimmed || - cleaned == "." || - cleaned == ".." || - strings.HasPrefix(cleaned, "../") { - return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) - } - return cleaned, directory, nil + return trimmed, 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) { + if err := safepath.ValidateRelative(entrypoint); err != nil { + return "", fmt.Errorf("%w: %q: %v", ErrEntrypointInvalid, entrypoint, err) + } + if entrypoint == "payload" || strings.HasPrefix(entrypoint, "payload/") { 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 + return entrypoint, nil } func exceedsCompressionRatio(uncompressed, compressed uint64, maximum float64) bool { diff --git a/core/installer/extractor_test.go b/core/installer/extractor_test.go index b047900..6154697 100644 --- a/core/installer/extractor_test.go +++ b/core/installer/extractor_test.go @@ -83,6 +83,70 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { limits: testLimits(), wantErr: ErrPathEscape, }, + { + name: "Windows normalized dot dot traversal", + entries: appendEntries(base, + testZIPEntry{name: "payload/.. /escape.exe", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "trailing period", + entries: appendEntries(base, + testZIPEntry{name: "payload/evil.exe.", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "trailing space", + entries: appendEntries(base, + testZIPEntry{name: "payload/evil.exe ", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "leading space", + entries: appendEntries(base, + testZIPEntry{name: "payload/ evil.exe", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "reserved device", + entries: appendEntries(base, + testZIPEntry{name: "payload/NUL.txt", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "reserved device with space before extension", + entries: appendEntries(base, + testZIPEntry{name: "payload/NUL .txt", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "reserved device superscript", + entries: appendEntries(base, + testZIPEntry{name: "payload/COM¹.log", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, + { + name: "forbidden Windows character", + entries: appendEntries(base, + testZIPEntry{name: "payload/evil?.exe", body: []byte("x")}), + entrypoint: "App.exe", + limits: testLimits(), + wantErr: ErrPathEscape, + }, { name: "backslash traversal", entries: appendEntries(base, @@ -127,6 +191,17 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { limits: testLimits(), wantErr: ErrDuplicateEntry, }, + { + name: "Unicode folded duplicate", + entries: []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/K.exe", body: []byte("one")}, + {name: "payload/K.exe", body: []byte("two")}, + }, + entrypoint: "K.exe", + limits: testLimits(), + wantErr: ErrDuplicateEntry, + }, { name: "unexpected top level entry", entries: appendEntries(base, @@ -184,7 +259,8 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { archivePath := writeTestZIP(t, test.entries) - destination := filepath.Join(t.TempDir(), "staging") + root := t.TempDir() + destination := filepath.Join(root, "staging") extractor := mustExtractor(t, test.limits) _, err := extractor.ExtractFile(archivePath, destination, test.entrypoint) @@ -194,6 +270,9 @@ func TestExtractorRejectsAttackArchives(t *testing.T) { if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { t.Fatalf("rejected archive left staging, stat error = %v", statErr) } + if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) { + t.Fatalf("rejected archive wrote outside staging, stat error = %v", statErr) + } }) } } @@ -210,6 +289,12 @@ func TestExtractorRejectsInvalidEntrypoints(t *testing.T) { {entrypoint: "../App.exe", wantErr: ErrEntrypointInvalid}, {entrypoint: "/App.exe", wantErr: ErrEntrypointInvalid}, {entrypoint: `..\App.exe`, wantErr: ErrEntrypointInvalid}, + {entrypoint: ".. /App.exe", wantErr: ErrEntrypointInvalid}, + {entrypoint: "App.exe.", wantErr: ErrEntrypointInvalid}, + {entrypoint: "App.exe ", wantErr: ErrEntrypointInvalid}, + {entrypoint: " App.exe", wantErr: ErrEntrypointInvalid}, + {entrypoint: "NUL", wantErr: ErrEntrypointInvalid}, + {entrypoint: "bad?.exe", wantErr: ErrEntrypointInvalid}, {entrypoint: "payload/App.exe", wantErr: ErrEntrypointInvalid}, {entrypoint: "Missing.exe", wantErr: ErrEntrypointMissing}, } @@ -229,6 +314,27 @@ func TestExtractorRejectsInvalidEntrypoints(t *testing.T) { } } +func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/工具/解析器.exe", body: []byte("executable")}, + {name: "payload/资源/说明.txt", body: []byte("说明")}, + }) + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + + result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe") + if err != nil { + t.Fatalf("ExtractFile() error = %v", err) + } + if result.Files != 2 { + t.Fatalf("Files = %d, want 2", result.Files) + } + if _, err := os.Stat(result.EntrypointPath); err != nil { + t.Fatalf("entrypoint stat error = %v", err) + } +} + func TestExtractorRejectsExistingDestination(t *testing.T) { archivePath := writeTestZIP(t, []testZIPEntry{ {name: "app.json", body: []byte(`{}`)}, diff --git a/core/installer/extractor_windows_test.go b/core/installer/extractor_windows_test.go new file mode 100644 index 0000000..3512a91 --- /dev/null +++ b/core/installer/extractor_windows_test.go @@ -0,0 +1,32 @@ +//go:build windows + +package installer + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: []byte(`{}`)}, + {name: "payload/App.exe", body: []byte("ok")}, + {name: "payload/.. /escape.exe", body: []byte("escape")}, + }) + root := t.TempDir() + destination := filepath.Join(root, "staging") + extractor := mustExtractor(t, testLimits()) + + _, err := extractor.ExtractFile(archivePath, destination, "App.exe") + if !errors.Is(err, ErrPathEscape) { + t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("rejected archive left staging, stat error = %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) { + t.Fatalf("archive escaped staging, stat error = %v", statErr) + } +} diff --git a/core/internal/safepath/path.go b/core/internal/safepath/path.go new file mode 100644 index 0000000..55b79c3 --- /dev/null +++ b/core/internal/safepath/path.go @@ -0,0 +1,167 @@ +// Package safepath validates package-controlled paths before they reach the +// Windows filesystem. +package safepath + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +var ( + ErrInvalidRelativePath = errors.New("invalid Windows relative path") + ErrOutsideRoot = errors.New("path is outside the managed root") +) + +// ValidateRelative accepts a canonical, slash-separated relative path that +// names a file or directory below a managed root. +func ValidateRelative(value string) error { + return validate(value, false) +} + +// ValidateWorkingDirectory accepts "." or a canonical relative directory. +func ValidateWorkingDirectory(value string) error { + return validate(value, true) +} + +func validate(value string, allowCurrent bool) error { + if value == "." && allowCurrent { + return nil + } + if value == "" { + return invalid("path is empty") + } + if !utf8.ValidString(value) { + return invalid("path is not valid UTF-8") + } + if strings.HasPrefix(value, "/") || path.IsAbs(value) { + return invalid("absolute paths are forbidden") + } + if strings.ContainsRune(value, '\\') { + return invalid("backslashes are forbidden") + } + if cleaned := path.Clean(value); cleaned != value { + return invalid("path is not canonical") + } + + segments := strings.Split(value, "/") + for _, segment := range segments { + if err := validateSegment(segment); err != nil { + return err + } + } + return nil +} + +func validateSegment(segment string) error { + if segment == "" { + return invalid("empty path segment") + } + if segment == "." || segment == ".." { + return invalid("relative dot segment") + } + if strings.HasPrefix(segment, " ") || + strings.HasSuffix(segment, " ") || + strings.HasSuffix(segment, ".") { + return invalid("path segment uses a Windows-trimmed character") + } + for _, character := range segment { + if unicode.IsControl(character) || strings.ContainsRune(`<>:"\|?*`, character) { + return invalid("path segment contains a Windows-forbidden character") + } + } + if isReservedDeviceName(segment) { + return invalid("path segment is a reserved Windows device name") + } + return nil +} + +func isReservedDeviceName(segment string) bool { + base := segment + if dot := strings.IndexRune(base, '.'); dot >= 0 { + base = base[:dot] + } + base = strings.TrimRight(base, " ") + upper := strings.ToUpper(base) + switch upper { + case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$": + return true + } + if len(upper) == 4 { + prefix := upper[:3] + digit := upper[3] + if (prefix == "COM" || prefix == "LPT") && digit >= '1' && digit <= '9' { + return true + } + } + if len([]rune(upper)) == 4 { + runes := []rune(upper) + prefix := string(runes[:3]) + digit := runes[3] + if (prefix == "COM" || prefix == "LPT") && + (digit == '\u00b9' || digit == '\u00b2' || digit == '\u00b3') { + return true + } + } + return false +} + +// CollisionKey returns a stable Unicode simple-fold key for case-insensitive +// duplicate detection. Callers should validate the path first. +func CollisionKey(value string) string { + var builder strings.Builder + builder.Grow(len(value)) + for _, character := range value { + builder.WriteRune(foldRune(character)) + } + return builder.String() +} + +func foldRune(character rune) rune { + smallest := character + for next := unicode.SimpleFold(character); next != character; next = unicode.SimpleFold(next) { + if next < smallest { + smallest = next + } + } + return smallest +} + +// JoinUnder joins a validated relative path below root and verifies the +// resulting native path remains lexically contained by that root. +func JoinUnder(root, relative string) (string, error) { + if root == "" { + return "", fmt.Errorf("%w: root is empty", ErrOutsideRoot) + } + if err := ValidateRelative(relative); err != nil { + return "", err + } + absoluteRoot, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("%w: resolve root: %v", ErrOutsideRoot, err) + } + absoluteRoot = filepath.Clean(absoluteRoot) + target := filepath.Clean(filepath.Join( + absoluteRoot, + filepath.FromSlash(relative), + )) + relativeTarget, err := filepath.Rel(absoluteRoot, target) + if err != nil { + return "", fmt.Errorf("%w: compare target: %v", ErrOutsideRoot, err) + } + if relativeTarget == ".." || + filepath.IsAbs(relativeTarget) || + strings.HasPrefix(relativeTarget, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("%w: %q", ErrOutsideRoot, relative) + } + return target, nil +} + +func invalid(reason string) error { + return fmt.Errorf("%w: %s", ErrInvalidRelativePath, reason) +} diff --git a/core/internal/safepath/path_test.go b/core/internal/safepath/path_test.go new file mode 100644 index 0000000..4f88d22 --- /dev/null +++ b/core/internal/safepath/path_test.go @@ -0,0 +1,138 @@ +package safepath + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestValidateRelative(t *testing.T) { + valid := []string{ + "JsonParser.exe", + "bin/JsonParser.exe", + "工具/解析器.exe", + ".config/settings.json", + "name..txt", + "folder with spaces/read me.txt", + } + for _, value := range valid { + t.Run("valid_"+value, func(t *testing.T) { + if err := ValidateRelative(value); err != nil { + t.Fatalf("ValidateRelative(%q) error = %v", value, err) + } + }) + } + + invalid := []string{ + "", + ".", + "..", + "../escape.exe", + "a/../escape.exe", + "./App.exe", + "a//App.exe", + "a/", + "/App.exe", + "//server/share/App.exe", + `C:/App.exe`, + `C:App.exe`, + `bin\App.exe`, + "bad\x00name.exe", + "bad\nname.exe", + "badname.exe", + `bad"name.exe`, + "bad|name.exe", + "bad?name.exe", + "bad*name.exe", + "folder./App.exe", + "folder /App.exe", + " App.exe", + "folder/ App.exe", + "App.exe.", + "App.exe ", + "CON", + "con.txt", + "PRN.json", + "AUX", + "NUL.bin", + "NUL .bin", + "COM1", + "com9.log", + "LPT1", + "lpt9.txt", + "COM¹", + "LPT³.log", + "CLOCK$", + "CONIN$", + "CONOUT$", + } + for _, value := range invalid { + t.Run("invalid_"+value, func(t *testing.T) { + err := ValidateRelative(value) + if !errors.Is(err, ErrInvalidRelativePath) { + t.Fatalf( + "ValidateRelative(%q) error = %v, want %v", + value, + err, + ErrInvalidRelativePath, + ) + } + }) + } +} + +func TestValidateWorkingDirectory(t *testing.T) { + for _, value := range []string{".", "bin", "资源/运行目录"} { + if err := ValidateWorkingDirectory(value); err != nil { + t.Fatalf("ValidateWorkingDirectory(%q) error = %v", value, err) + } + } + for _, value := range []string{"", "..", "bin.", "NUL"} { + if err := ValidateWorkingDirectory(value); !errors.Is(err, ErrInvalidRelativePath) { + t.Fatalf( + "ValidateWorkingDirectory(%q) error = %v, want %v", + value, + err, + ErrInvalidRelativePath, + ) + } + } +} + +func TestCollisionKeyUsesUnicodeSimpleFold(t *testing.T) { + tests := [][2]string{ + {"App.EXE", "app.exe"}, + {"K.exe", "K.exe"}, + {"Σ.txt", "ς.txt"}, + } + for _, values := range tests { + if CollisionKey(values[0]) != CollisionKey(values[1]) { + t.Fatalf( + "CollisionKey(%q) != CollisionKey(%q)", + values[0], + values[1], + ) + } + } +} + +func TestJoinUnder(t *testing.T) { + root := filepath.Join(t.TempDir(), "staging") + target, err := JoinUnder(root, "bin/App.exe") + if err != nil { + t.Fatalf("JoinUnder() error = %v", err) + } + want := filepath.Join(root, "bin", "App.exe") + if target != want { + t.Fatalf("JoinUnder() = %q, want %q", target, want) + } + + for _, value := range []string{"../escape.exe", ".. /escape.exe", "/escape.exe"} { + _, err := JoinUnder(root, value) + if !errors.Is(err, ErrInvalidRelativePath) && + !errors.Is(err, ErrOutsideRoot) { + t.Fatalf("JoinUnder(%q) error = %v", value, err) + } + } +} diff --git a/core/storage/installed_app.go b/core/storage/installed_app.go index 56bf078..6631031 100644 --- a/core/storage/installed_app.go +++ b/core/storage/installed_app.go @@ -7,13 +7,12 @@ import ( "fmt" "io" "os" - "path" "path/filepath" "regexp" - "strings" "sync" "softbox.local/core/domain" + "softbox.local/core/internal/safepath" ) const ( @@ -281,12 +280,13 @@ func (record InstalledApp) validate() error { seenPaths := make(map[string]struct{}, len(record.Files)) for index, installedFile := range record.Files { - if !validInstalledPath(installedFile.Path) { + if err := safepath.ValidateRelative(installedFile.Path); err != nil { return fmt.Errorf( - "%w: files[%d].path=%q", + "%w: files[%d].path=%q: %v", ErrInstalledAppInvalid, index, installedFile.Path, + err, ) } if installedFile.Size < 0 { @@ -304,7 +304,7 @@ func (record InstalledApp) validate() error { index, ) } - foldedPath := strings.ToLower(installedFile.Path) + foldedPath := safepath.CollisionKey(installedFile.Path) if _, exists := seenPaths[foldedPath]; exists { return fmt.Errorf( "%w: duplicate file path %q", @@ -317,18 +317,6 @@ func (record InstalledApp) validate() error { return nil } -func validInstalledPath(value string) bool { - if value == "" || strings.Contains(value, `\`) || strings.Contains(value, ":") { - return false - } - cleaned := path.Clean(value) - return cleaned == value && - cleaned != "." && - !strings.HasPrefix(cleaned, "/") && - cleaned != ".." && - !strings.HasPrefix(cleaned, "../") -} - func requireRealDirectory(directory string) error { info, err := os.Lstat(directory) if err != nil { diff --git a/core/storage/installed_app_test.go b/core/storage/installed_app_test.go index 9fb408b..2e44938 100644 --- a/core/storage/installed_app_test.go +++ b/core/storage/installed_app_test.go @@ -74,6 +74,42 @@ func TestInstalledAppStoreRejectsInvalidRecords(t *testing.T) { record.Files[0].Path = "../escape.exe" }, }, + { + name: "Windows normalized escape", + mutate: func(record *InstalledApp) { + record.Files[0].Path = ".. /escape.exe" + }, + }, + { + name: "trailing period", + mutate: func(record *InstalledApp) { + record.Files[0].Path = "JsonParser.exe." + }, + }, + { + name: "trailing space", + mutate: func(record *InstalledApp) { + record.Files[0].Path = "JsonParser.exe " + }, + }, + { + name: "leading space", + mutate: func(record *InstalledApp) { + record.Files[0].Path = " JsonParser.exe" + }, + }, + { + name: "reserved device", + mutate: func(record *InstalledApp) { + record.Files[0].Path = "NUL.txt" + }, + }, + { + name: "forbidden Windows character", + mutate: func(record *InstalledApp) { + record.Files[0].Path = "Json?Parser.exe" + }, + }, { name: "invalid hash", mutate: func(record *InstalledApp) { @@ -103,6 +139,22 @@ func TestInstalledAppStoreRejectsInvalidRecords(t *testing.T) { } } +func TestInstalledAppStoreAcceptsUnicodeNestedPath(t *testing.T) { + record := validInstalledApp() + record.Files[0].Path = "工具/解析器.exe" + store := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")) + if err := store.Write(record); err != nil { + t.Fatalf("Write() error = %v", err) + } + loaded, found, err := store.Read(record.ID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if !found || loaded.Files[0].Path != record.Files[0].Path { + t.Fatalf("Read() = %#v, found=%t", loaded, found) + } +} + func TestInstalledAppStoreRejectsUnknownFieldsAndIDMismatch(t *testing.T) { appsRoot := filepath.Join(t.TempDir(), "apps") appRoot := filepath.Join(appsRoot, "json-parser") diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index ba35793..d3005c3 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -45,7 +45,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与首个交叉审核整改 `T-604`。Phase 1 安全交叉审核已定稿,`T-605` 已落成待领取;先关闭 Windows 安全路径阻断项,再按审核顺序处理中央目录、断电耐久和签名向量,之后恢复 T-302。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`、`T-605`。Windows 安全路径阻断项已关闭;下一步按 Phase 1 安全交叉审核顺序落成中央目录预扫描整改,随后处理断电耐久和签名向量,之后恢复 T-302。 优先路径: @@ -53,7 +53,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 下一步领取 T-605,继续串行完成 Phase 1 安全阻断整改,再恢复 T-302/T-303 和 Phase 4-6。 +5. 下一步落成 ZIP 中央目录预扫描整改任务,继续串行完成 Phase 1 安全阻断整改,再恢复 T-302/T-303 和 Phase 4-6。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 150641e..b41bd16 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -72,6 +72,7 @@ soft_quay/ │ ├─ application/ │ ├─ catalog/ # 清单获取、验签、缓存、过滤 │ ├─ downloader/ # 下载队列、断点续传、任务持久化 +│ ├─ internal/safepath/ # 跨 catalog/installer/storage 的 Windows 安全相对路径规则 │ ├─ installer/ # ZIP 校验、安全解压、staging/backup/回滚 │ ├─ licensing/ # Ed25519 许可证验证、machine_hash │ ├─ storage/ # JSON 原子读写、目录布局 @@ -109,6 +110,7 @@ soft_quay/ - 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。 - Catalog 正式加载顺序为 HTTPS 获取 → 验签 → 严格字段/Schema/channel 校验 → 缓存替换 → 目标过滤;签名正确但结构或目标通道不匹配的远端内容不能挤掉最后可消费缓存。 - 清单解析拒绝未知字段、重复字段/软件 ID、尾随 JSON、非整数数字、非 HTTPS URL 与 architectures/packages 映射不一致;签名域为移除顶层 `signature` 后的受限规范 JSON,细节见 [api.md](api.md)。 +- Catalog `entry_exe` 与后续 app.json/files.json 路径必须复用 `core/internal/safepath`,不能由各协议解析器分别维护 Windows 路径规则。 - channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。 - `category` 提供稳定单分类,`tags` 用于搜索和多标签展示;hidden 项从远端目录隐藏,deprecated 与不兼容项保留可见原因但不提供安装包操作。 @@ -137,7 +139,7 @@ soft_quay/ T-202 将本地识别拆为两层: -- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在。 +- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。 - `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。 磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。 @@ -169,9 +171,9 @@ T-301 下载队列: → 健康检查 → 成功延迟清理 backup / 失败恢复 backup ``` -必须防止:绝对路径、`../` 穿越、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 +必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。 -Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、路径、类型、重复项、entrypoint 与资源上限,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。 +Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与资源上限,再规划并确认所有 native 输出路径仍在 destination 内,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。 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 a7031fe..27390be 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -30,7 +30,8 @@ ## 3. 安全纪律(违反即安全事故) - 任何下载内容未通过 SHA-256 + 签名验证,**不得解压执行**;清单验签失败拒绝,不回退到未验证内容。 -- ZIP 处理必须拒绝:绝对路径、`../` 穿越、符号链接逃逸、entrypoint 指向 payload 外、无上限解压(文件数/体积/压缩比)。 +- ZIP 与软件包路径必须使用 `core/internal/safepath` 的共享 Windows 安全相对路径策略,拒绝绝对路径、`../`/dot-space 归一化穿越、首尾 ASCII 空格、尾随句点、DOS 设备名、Windows 禁止字符、符号链接逃逸和 entrypoint 指向 payload 外;native 输出路径还必须验证仍在 destination 内。Catalog/storage/app.json/files.json 不得各自复制一套路径规则。 +- ZIP 解压必须保留文件数、展开体积和压缩比硬上限。 - 安装/更新只走 `staging → current → backup` 原子流程;任何写 `current/` 的捷径都不允许。 - 程序更新不得触碰 `data/` 与 `licenses/`。 - 不强杀用户进程;更新前等待正常退出,超时取消。 diff --git a/docs/api.md b/docs/api.md index ce35ef5..cd14957 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,6 +10,7 @@ - 签名:Ed25519;客户端只内置公钥;示例中的 `"signature": "..."` 均为占位符。 - 版本号:语义化版本(SemVer)。 - 软件 ID:`^[a-z0-9-]+$`,永久稳定,发布后不得更改。 +- 软件包控制的路径统一使用 UTF-8 与 `/` 分隔,并由客户端共享的 Windows 安全相对路径策略校验。JSON Schema 只表达基础形状;运行时还逐段拒绝空段、`.`/`..`、首尾 ASCII 空格、尾随句点、控制字符、Windows 禁止字符和 DOS 设备名及其兼容变体。 ## 1. Catalog 清单(远端 → 盒子) @@ -120,7 +121,7 @@ json-parser_1.4.2_windows_amd64.zip } ``` -校验规则:`entrypoint`/`working_directory` 必须是 payload 内安全相对路径;`id`、`version`、`channel`、`architecture` 必须与 Catalog 记录一致;`schema_version` 高于盒子支持范围时拒绝安装并提示升级盒子(盒子始终支持当前与前一个 Schema)。 +校验规则:`entrypoint`/`working_directory` 必须通过共享 Windows 安全相对路径策略(`working_directory` 额外允许 `.`),并位于 payload 内;`id`、`version`、`channel`、`architecture` 必须与 Catalog 记录一致;`schema_version` 高于盒子支持范围时拒绝安装并提示升级盒子(盒子始终支持当前与前一个 Schema)。 ### 2.2 files.json @@ -137,15 +138,16 @@ json-parser_1.4.2_windows_amd64.zip ### 2.3 安全限制(必须拒绝) -绝对路径;`../` 穿越;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。 +绝对路径;`../` 与 Windows `".. "` 等归一化穿越;首尾 ASCII 空格/尾随句点造成的路径别名;DOS 设备名;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。 T-102 Phase 1 原型进一步固定: -- ZIP 名称只接受 UTF-8 `/` 分隔的规范相对路径;拒绝反斜杠、盘符、冒号/NTFS ADS、NUL、`.`/`..` 和大小写折叠后的重复输出路径。 +- 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 按真实包体分布复核后再冻结。 - entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。 +- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。 ### 2.4 安装记录 installed-app.json(本地) @@ -170,7 +172,7 @@ T-102 Phase 1 原型进一步固定: 规则: -- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、安全相对路径以外的文件名、重复路径和非法 SHA-256 均拒绝。 +- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、共享 Windows 安全相对路径以外的文件名、大小写折叠重复路径和非法 SHA-256 均拒绝。 - `files` 必须是数组;v1 可以为空,完整文件清单由 T-302 安装整合时从已验证包写入。 - 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。 - SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。 diff --git a/docs/current-state.md b/docs/current-state.md index 91b0333..7a20c87 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,24 +13,24 @@ ## 当前快照 - 日期:2026-07-16 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;T-604 已完成;Phase 1 首个安全整改 T-605 已落成待领取,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604、T-605 已完成,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/本地状态/存储、无 IO 软件列表模型、可信图标缓存和默认并发 2 的持久可恢复下载队列;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与内存图标 -- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、列表/图标、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件稳定性、详情/ApplyIcon 与平台 stub;ZIP/安装恢复矩阵保持通过 -- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、可信图标缓存和默认并发 2 的持久可恢复下载队列;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与内存图标 +- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件稳定性、详情/ApplyIcon 与平台 stub;安装恢复矩阵保持通过 +- 数据:`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-605 Windows 安全路径整改,完成 Phase 1 阻断项后再恢复 T-302 安装流程整合 +- 当前 blocker:无;下一步按 Phase 1 安全审核定稿落成 ZIP 中央目录预扫描整改任务,T-302 继续后置 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604 已完成;T-605 已落成待领取;T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301、T-604 与 T-605 已完成;下一 Phase 1 安全整改尚未落成;T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、列表模型、图标缓存、可恢复下载队列与 Phase 1 安装安全原型 | +| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、图标缓存、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和内存图标 | | `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情和内存图标 | | `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` | @@ -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`。 +- 已完成: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-605`。 - 正在进行:无。 -- 下一个可领取任务:`T-605 统一 Windows 安全路径校验并封堵 ZIP 逃逸`。 +- 下一个可领取任务:无;先按 `docs/review/phase1-security-review.md` 落成 ZIP 中央目录预扫描整改任务。 ## 当前可运行内容 diff --git a/docs/tasks/T-605.md b/docs/tasks/T-605.md index e8d73ae..9481a74 100644 --- a/docs/tasks/T-605.md +++ b/docs/tasks/T-605.md @@ -3,12 +3,12 @@ id: T-605 title: 统一 Windows 安全路径校验并封堵 ZIP 逃逸 phase: 1 deps: [T-102, T-201, T-202, T-604] -status: TODO +status: DONE created: 2026-07-16 issue: null -context_ref: null +context_ref: f7a803d944d3ef1c782aa974d5114f51d3e00b17 claim_branch: null -work_branch: null +work_branch: agent/codex/T-605 write_paths: - docs/tasks/T-605.md - core/internal/safepath/ @@ -26,6 +26,7 @@ write_paths: - docs/api.md - docs/04-architecture.md - docs/05-coding-rules.md + - docs/00-ai-start-here.md - docs/current-state.md --- @@ -42,7 +43,7 @@ Extractor 的正式调用约束是 Catalog 签名与 ZIP SHA-256 已验证后才 1. 在 `core/internal/safepath` 建立 Go 1.20 兼容、无 Windows API 依赖的共享 Windows 安全相对路径策略: - 输入统一使用 UTF-8 与 `/` 分隔。 - 拒绝空路径、绝对/UNC/盘符/ADS、反斜杠、NUL、控制字符和 Windows 禁止字符。 - - 逐段拒绝空段、`.`、`..`、尾随 ASCII 空格/句点。 + - 逐段拒绝空段、`.`、`..`、首尾 ASCII 空格和尾随句点。 - 大小写不敏感地拒绝 DOS 设备名及其在 Win7~Win11 需要兼容的扩展/变体。 - 分别支持“必须指向文件”和“工作目录可为 `.`”两类调用语义,避免各模块自行放宽。 - 提供统一的 Windows 大小写折叠冲突键,替换 catalog/installer/storage 中分散的路径规则。 @@ -67,7 +68,7 @@ Extractor 的正式调用约束是 Catalog 签名与 ZIP SHA-256 已验证后才 - destination 包含性兜底对文件、目录和最终 entrypoint 都生效,且不能通过混合分隔符或路径清理结果绕过。 - 合法 UTF-8/中文文件名、嵌套路径和 Catalog/installed-app 既有合法样例继续通过。 - Windows 专属测试在当前可用 Windows 环境实际执行;测试代码须可在 Win7、Win10、Win11 复用。若本任务环境不能覆盖三套系统,执行记录必须写明已测系统和待 T-601 补齐的 VM/真机矩阵。 -- `cd core && GOTOOLCHAIN=go1.20.14 go vet ./... && GOTOOLCHAIN=go1.20.14 go test -count=1 ./...` 通过。 +- `cd core && GOWORK=off GOTOOLCHAIN=go1.20.14 go vet ./... && GOWORK=off GOTOOLCHAIN=go1.20.14 go test -count=1 ./...` 通过。 - `./scripts/verify_phase0.ps1` 与 `bash scripts/verify_phase0.sh` 全绿,modern/Win7 双目标继续构建。 - `python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 通过。 @@ -91,3 +92,9 @@ Extractor 的正式调用约束是 Catalog 签名与 ZIP SHA-256 已验证后才 - 2026-07-16:根据 `docs/review/phase1-security-review.md` 交叉复核定稿的最高优先级整改落成任务;现有全局最大任务为 T-604,因此取 T-605。 - 2026-07-16:任务风险按定稿校准为“签名内容管线的纵深防御缺口”,同时保留阻断 T-302 的结论;本任务只收口 Windows 路径规则与 destination 纵深检查,中央目录、断电耐久和签名向量继续串行拆分。 +- 2026-07-16:在 `agent/codex/T-605` 分支领取任务,基线为 `f7a803d944d3ef1c782aa974d5114f51d3e00b17`;保持单 Agent 串行执行。 +- 2026-07-16:新增 `core/internal/safepath`,统一拒绝非规范/绝对路径、反斜杠/ADS、控制字符、Windows 禁止字符、首尾 ASCII 空格、尾随句点及 DOS 设备名(含扩展、上标数字和兼容变体);Unicode simple-fold 冲突键同时替换 installer/storage 中分散的 `strings.ToLower`。 +- 2026-07-16:Catalog `entry_exe`、ZIP entry/entrypoint、installed-app `files[].path` 全部接入共享规则;Extractor 在创建 staging 前规划所有绝对输出路径,用 destination 相对包含性检查覆盖文件、目录与最终 entrypoint。攻击矩阵新增 `payload/.. /escape.exe`、dot-space、设备名、禁止字符和 Unicode 折叠冲突,并断言 staging 外无文件。 +- 2026-07-16:三个相关 Schema 加强可表达的路径形状约束并声明运行时校验器为 Windows 设备名/控制字符的权威;`api.md`、架构、编码规则和 ZIP testdata 说明同步。Schema JSON/正则 smoke 对合法中文与 `../`、嵌套 `..`、尾随点、首部空格、双斜杠、`?` 拒绝均通过。 +- 2026-07-16:Windows 原生测试 `TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem` 在 Windows 10 专业版 10.0.19045 amd64 通过;测试代码带 `windows` build tag,可直接用于 Win7/Win11。当前环境没有 Win7/Win11 VM,两者实际矩阵按验收边界留 T-601 补齐。 +- 2026-07-16:验证通过:Go 1.20.14 + `GOWORK=off` 执行 `go vet ./...`、`go test -count=1 ./...` 与 Windows 定向测试;`./scripts/verify_phase0.ps1`;`bash scripts/verify_phase0.sh`;modern Go 1.25.0 与 Win7 Go 1.20.14 双目标测试/构建;`python scripts/validate_agent_context.py`;`python scripts/validate_harness_governance.py`。 diff --git a/schemas/app.schema.json b/schemas/app.schema.json index 28313bc..80511b1 100644 --- a/schemas/app.schema.json +++ b/schemas/app.schema.json @@ -84,7 +84,8 @@ "safeRelativePath": { "type": "string", "minLength": 1, - "pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$" + "$comment": "Runtime validation additionally rejects invalid UTF-8/control characters and Windows reserved device names; this pattern is only a basic shape constraint.", + "pattern": "^(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+$" } } } diff --git a/schemas/installed-app.schema.json b/schemas/installed-app.schema.json index f7c0da2..7b4487f 100644 --- a/schemas/installed-app.schema.json +++ b/schemas/installed-app.schema.json @@ -46,7 +46,8 @@ "path": { "type": "string", "minLength": 1, - "pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$" + "$comment": "Runtime validation additionally rejects invalid UTF-8/control characters and Windows reserved device names; this pattern is only a basic shape constraint.", + "pattern": "^(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+$" }, "size": { "type": "integer", diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index b52d414..6ddafcb 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -150,7 +150,8 @@ "entry_exe": { "type": "string", "minLength": 1, - "pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$" + "$comment": "Runtime validation additionally rejects invalid UTF-8/control characters and Windows reserved device names; this pattern is only a basic shape constraint.", + "pattern": "^(?!/)(?! )(?!.*(/ ))(?!.*\\\\)(?!.*[:<>\"|?*])(?!\\.\\.?(/|$))(?!.*(/\\.\\.?)(/|$))(?!.*//)(?!.*[ .](/|$)).+$" }, "requires_admin": { "type": "boolean" diff --git a/testdata/zip/README.md b/testdata/zip/README.md index cb9d63e..61a398d 100644 --- a/testdata/zip/README.md +++ b/testdata/zip/README.md @@ -3,9 +3,11 @@ T-102 的表驱动测试在运行时生成 ZIP,避免提交难审查的二进制文件。覆盖: - 绝对路径、盘符、NTFS ADS、`../`、反斜杠穿越。 -- 符号链接、特殊文件、大小写折叠重复路径、协议外顶层文件。 +- Windows `".. "` 归一化穿越、首尾 ASCII 空格、尾随句点、DOS 设备名/扩展/上标数字变体与 Windows 禁止字符。 +- 符号链接、特殊文件、Unicode 简单折叠/大小写重复路径、协议外顶层文件。 - 条目数、总解压体积、压缩比上限。 - entrypoint 绝对/穿越/重复 `payload/` 前缀/不存在。 - 数据 CRC 损坏导致复制失败时清理 staging。 +- 创建 staging 前规划全部 native 输出路径并验证 destination 包含性;拒绝样例不得在 staging 外留下文件。 所有内容均为测试专用,不包含真实软件包或生产地址。