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
+167
View File
@@ -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)
}
+138
View File
@@ -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)
}
}
}