fix: contain generated files and assets and harden SQL rendering
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Package safefile provides portable path validation and root-scoped file access.
|
||||
package safefile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Relative validates artifact paths consistently on Unix and Windows. It rejects
|
||||
// traversal rather than normalizing it, so aliases cannot hide filename collisions.
|
||||
func Relative(name string) (string, error) {
|
||||
if name == "" || !filepath.IsLocal(name) || strings.ContainsAny(name, `\:<>"|?*`) {
|
||||
return "", fmt.Errorf("unsafe relative path %q", name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if unicode.IsControl(r) {
|
||||
return "", fmt.Errorf("control character in path %q", name)
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(filepath.ToSlash(name), "/") {
|
||||
if part == "" || part == "." || part == ".." || strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") {
|
||||
return "", fmt.Errorf("unsafe path component in %q", name)
|
||||
}
|
||||
base := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
|
||||
device := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || base == "CONIN$" || base == "CONOUT$"
|
||||
for _, prefix := range []string{"COM", "LPT"} {
|
||||
if strings.HasPrefix(base, prefix) {
|
||||
suffix := []rune(strings.TrimPrefix(base, prefix))
|
||||
device = device || (len(suffix) == 1 && strings.ContainsRune("123456789¹²³", suffix[0]))
|
||||
}
|
||||
}
|
||||
if device {
|
||||
return "", fmt.Errorf("reserved device path %q", name)
|
||||
}
|
||||
}
|
||||
return filepath.FromSlash(name), nil
|
||||
}
|
||||
|
||||
// Check rejects existing symlinks and special files; os.Root separately enforces
|
||||
// containment at open/rename time, including if a symlink changes after this check.
|
||||
func Check(root *os.Root, name string) error {
|
||||
if _, err := Relative(filepath.ToSlash(name)); err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.Split(name, string(filepath.Separator))
|
||||
current := ""
|
||||
for i, part := range parts {
|
||||
current = filepath.Join(current, part)
|
||||
info, err := root.Lstat(current)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink is not allowed in path %q", name)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("parent is not a directory in path %q", name)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("path is not a regular file: %q", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read opens through a pinned root and bounds both the declared and actual size.
|
||||
func Read(root *os.Root, name string, maxBytes int64) ([]byte, error) {
|
||||
if maxBytes <= 0 || maxBytes == (1<<63)-1 {
|
||||
return nil, fmt.Errorf("file size limit must be positive and bounded")
|
||||
}
|
||||
if err := Check(root, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := root.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("asset is not a regular file: %q", name)
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return nil, fmt.Errorf("asset %q exceeds %d byte limit", name, maxBytes)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("asset %q exceeds %d byte limit", name, maxBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// CheckRoot rejects symlinks in the configured root or its existing ancestors.
|
||||
// The configured root is a trusted caller-selected boundary; OpenRoot pins it for
|
||||
// subsequent operations. Missing components are allowed for output root creation.
|
||||
func CheckRoot(path string) error {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
volume := filepath.VolumeName(absolute)
|
||||
current := volume + string(filepath.Separator)
|
||||
for _, part := range strings.Split(strings.TrimPrefix(absolute, current), string(filepath.Separator)) {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, part)
|
||||
info, err := os.Lstat(current)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink is not allowed in root path %q", path)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("root parent is not a directory: %q", current)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user