Harden Windows package paths (T-605)

This commit is contained in:
ila
2026-07-16 23:56:19 +08:00
parent f7a803d944
commit 6fd19d0f43
19 changed files with 664 additions and 108 deletions
+107 -1
View File
@@ -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(`{}`)},