Harden Windows package paths (T-605)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user