Harden Windows package paths (T-605)
This commit is contained in:
+7
-15
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
+72
-47
@@ -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 {
|
||||
|
||||
@@ -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(`{}`)},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
"bad<name.exe",
|
||||
"bad>name.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user