397 lines
14 KiB
Go
397 lines
14 KiB
Go
// Package safeio reads and writes local files without following symlinked path components.
|
|
package safeio
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var ErrUnsafeFile = errors.New("unsafe file")
|
|
|
|
// Pagination must never allocate or iterate indefinitely over a hostile private directory. This
|
|
// is deliberately independent from an auth record page: it permits many bounded pages while
|
|
// putting a fixed upper bound on one sorted keyset scan.
|
|
const maximumPrivateDirectoryPageScanEntries = 16384
|
|
|
|
// EnsurePrivateDirectory creates only the final canonical directory with the platform's
|
|
// owner-only protection, or validates an existing directory has that protection.
|
|
func EnsurePrivateDirectory(path string) error {
|
|
exists, err := PreflightPrivateDirectory(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return ValidatePrivateDirectory(path)
|
|
}
|
|
if err := createPrivateDirectory(path); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return ErrUnsafeFile
|
|
}
|
|
return ValidatePrivateDirectory(path)
|
|
}
|
|
|
|
// PreflightPrivateDirectory validates every existing path component without following links or
|
|
// reparse points. A missing final component is safe to create later; missing intermediates are not.
|
|
// This function never creates, removes, chmods, or changes an ACL.
|
|
func PreflightPrivateDirectory(path string) (bool, error) {
|
|
if err := ValidateCanonicalPath(path); err != nil {
|
|
return false, ErrUnsafeFile
|
|
}
|
|
exists, err := preflightPrivateDirectory(path)
|
|
if err != nil {
|
|
return false, ErrUnsafeFile
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
|
|
func ValidateCanonicalPath(path string) error {
|
|
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
|
return ErrUnsafeFile
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readBoundedRegularFile(path string, file *os.File, maximum int64) ([]byte, error) {
|
|
if maximum < 0 || maximum == int64(^uint64(0)>>1) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil || !info.Mode().IsRegular() || !hasSingleLink(info) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
contents, err := io.ReadAll(io.LimitReader(file, maximum+1))
|
|
if err != nil || int64(len(contents)) > maximum {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
after, err := file.Stat()
|
|
if err != nil || !after.Mode().IsRegular() || !hasSingleLink(after) || !os.SameFile(info, after) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
current, err := os.Lstat(path)
|
|
if err != nil || !current.Mode().IsRegular() || current.Mode()&os.ModeSymlink != 0 || !hasSingleLink(current) || !os.SameFile(info, current) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
return contents, nil
|
|
}
|
|
|
|
func ReadCanonicalUTF8(path string, maximum int64) (string, error) {
|
|
contents, err := ReadCanonicalRegular(path, maximum)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !utf8.Valid(contents) {
|
|
return "", ErrUnsafeFile
|
|
}
|
|
return string(contents), nil
|
|
}
|
|
|
|
// ReadCanonicalPrivateRegular reads one owner-only private record and revalidates its metadata
|
|
// after the bounded read. It intentionally rejects ordinary hard links; OIDC's explicitly named
|
|
// atomic claim pair uses ReadCanonicalPrivateClaim instead.
|
|
func ReadCanonicalPrivateRegular(path string, maximum int64) ([]byte, error) {
|
|
return readCanonicalPrivateRegular(path, maximum)
|
|
}
|
|
|
|
// PrivateDirectoryEntry is a bounded, untrusted directory listing item. Callers must still
|
|
// validate each filename and record before using it.
|
|
type PrivateDirectoryEntry struct {
|
|
Name string `json:"name"`
|
|
ModifiedUnixMs int64 `json:"modifiedUnixMs"`
|
|
}
|
|
|
|
// PrivateDirectoryPage is one lexically ordered, keyset-paginated directory page. More is true
|
|
// only when the page is full and another validated entry follows AfterName.
|
|
type PrivateDirectoryPage struct {
|
|
Entries []PrivateDirectoryEntry
|
|
More bool
|
|
}
|
|
|
|
func privateDirectoryScanSnapshot(path string) (os.FileInfo, error) {
|
|
if err := ValidatePrivateDirectory(path); err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func samePrivateDirectoryScanSnapshot(left, right os.FileInfo) bool {
|
|
return left != nil && right != nil && os.SameFile(left, right) && left.Mode() == right.Mode() && left.ModTime().Equal(right.ModTime())
|
|
}
|
|
|
|
// ListCanonicalPrivateDirectory lists regular, non-symlinked direct children from an owner-only
|
|
// directory. It returns no content and bounds the number of entries before allocating output.
|
|
func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDirectoryEntry, error) {
|
|
if maximumEntries < 1 || maximumEntries > 4096 {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
if err := ValidatePrivateDirectory(path); err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
directory, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
defer directory.Close()
|
|
entries, err := directory.ReadDir(maximumEntries + 1)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
if len(entries) > maximumEntries {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
result := make([]PrivateDirectoryEntry, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.Name() == "" || strings.Contains(entry.Name(), string(filepath.Separator)) {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
info, err := os.Lstat(filepath.Join(path, entry.Name()))
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
result = append(result, PrivateDirectoryEntry{Name: entry.Name(), ModifiedUnixMs: info.ModTime().UnixMilli()})
|
|
}
|
|
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
|
|
if err := ValidatePrivateDirectory(path); err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListCanonicalPrivateDirectoryPage scans a private directory without materializing a complete
|
|
// output listing, validates every encountered child, and keeps only the next bounded lexical page.
|
|
// The caller supplies the filename grammar because safeio is intentionally storage-format agnostic.
|
|
func ListCanonicalPrivateDirectoryPage(
|
|
path string,
|
|
maximumEntries int,
|
|
afterName string,
|
|
validName func(string) bool,
|
|
) (PrivateDirectoryPage, error) {
|
|
if maximumEntries < 1 || maximumEntries > 4096 || validName == nil || (afterName != "" && !validName(afterName)) {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
before, err := privateDirectoryScanSnapshot(path)
|
|
if err != nil {
|
|
return PrivateDirectoryPage{}, err
|
|
}
|
|
directory, err := os.Open(path)
|
|
if err != nil {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
defer directory.Close()
|
|
|
|
seen := make(map[string]struct{}, maximumEntries+1)
|
|
selected := make([]PrivateDirectoryEntry, 0, maximumEntries+1)
|
|
scanned := 0
|
|
for {
|
|
entries, readErr := directory.ReadDir(1)
|
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
if len(entries) == 0 {
|
|
break
|
|
}
|
|
if len(entries) != 1 {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
scanned++
|
|
if scanned > maximumPrivateDirectoryPageScanEntries {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
name := entries[0].Name()
|
|
if name == "" || strings.Contains(name, string(filepath.Separator)) || !validName(name) {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
if _, duplicate := seen[name]; duplicate {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
seen[name] = struct{}{}
|
|
entryPath := filepath.Join(path, name)
|
|
if err := ValidatePrivateRegular(entryPath); err != nil {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
info, err := os.Lstat(entryPath)
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || !hasSingleLink(info) {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
if name > afterName {
|
|
selected = appendBoundedPrivateDirectoryEntry(selected, PrivateDirectoryEntry{
|
|
Name: name, ModifiedUnixMs: info.ModTime().UnixMilli(),
|
|
}, maximumEntries+1)
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
}
|
|
after, err := privateDirectoryScanSnapshot(path)
|
|
if err != nil || !samePrivateDirectoryScanSnapshot(before, after) {
|
|
return PrivateDirectoryPage{}, ErrUnsafeFile
|
|
}
|
|
sort.Slice(selected, func(left, right int) bool { return selected[left].Name < selected[right].Name })
|
|
more := len(selected) > maximumEntries
|
|
if more {
|
|
selected = selected[:maximumEntries]
|
|
}
|
|
return PrivateDirectoryPage{Entries: selected, More: more}, nil
|
|
}
|
|
|
|
func appendBoundedPrivateDirectoryEntry(
|
|
entries []PrivateDirectoryEntry,
|
|
entry PrivateDirectoryEntry,
|
|
maximumEntries int,
|
|
) []PrivateDirectoryEntry {
|
|
if len(entries) < maximumEntries {
|
|
return append(entries, entry)
|
|
}
|
|
maximum := 0
|
|
for index := 1; index < len(entries); index++ {
|
|
if entries[index].Name > entries[maximum].Name {
|
|
maximum = index
|
|
}
|
|
}
|
|
if entry.Name < entries[maximum].Name {
|
|
entries[maximum] = entry
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error {
|
|
return writeCanonicalNewFile(path, contents, mode, false)
|
|
}
|
|
|
|
// WriteCanonicalNewPrivateFile is the authentication-storage variant of exclusive file
|
|
// creation. It requires the final parent directory to already have platform-specific owner-only
|
|
// protection and preserves that check while the platform primitive opens the parent.
|
|
func WriteCanonicalNewPrivateFile(path string, contents []byte, mode os.FileMode) error {
|
|
return writeCanonicalNewFile(path, contents, mode, true)
|
|
}
|
|
|
|
// CreateCanonicalNewPrivateFile exclusively creates an owner-private regular file under an
|
|
// already private parent and returns a read/write handle for streamed contents. Callers must close
|
|
// the returned handle and remove the file if their stream fails.
|
|
func CreateCanonicalNewPrivateFile(path string) (*os.File, error) {
|
|
if err := validateCanonicalNewFile(path); err != nil {
|
|
return nil, err
|
|
}
|
|
file, err := createCanonicalNewPrivateParentReadWriteFile(path, 0o600)
|
|
if err != nil {
|
|
return nil, ErrUnsafeFile
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
func writeCanonicalNewFile(path string, contents []byte, mode os.FileMode, requirePrivateParent bool) error {
|
|
if err := validateCanonicalNewFile(path); err != nil {
|
|
return err
|
|
}
|
|
var (
|
|
file *os.File
|
|
err error
|
|
)
|
|
if requirePrivateParent {
|
|
file, err = createCanonicalNewPrivateParentFile(path, mode)
|
|
} else {
|
|
file, err = createCanonicalNewPrivateFile(path, mode)
|
|
}
|
|
if err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
if _, err := file.Write(contents); err != nil {
|
|
_ = file.Close()
|
|
_ = RemoveCanonicalPrivateRegular(path)
|
|
return ErrUnsafeFile
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
_ = RemoveCanonicalPrivateRegular(path)
|
|
return ErrUnsafeFile
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
_ = RemoveCanonicalPrivateRegular(path)
|
|
return ErrUnsafeFile
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateCanonicalNewFile performs only lexical validation. Platform creators retain and
|
|
// inspect the actual parent handle before mutation; pathname existence or parent checks here
|
|
// would be stale by the time an exclusive create reaches the filesystem.
|
|
func validateCanonicalNewFile(path string) error {
|
|
if err := ValidateCanonicalPath(path); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReplaceCanonicalRegular durably replaces one existing private regular file without following
|
|
// symlinked path components. Platform implementations keep the temporary file in the target
|
|
// directory and use the platform's atomic replace primitive.
|
|
func ReplaceCanonicalRegular(path string, contents []byte, mode os.FileMode) error {
|
|
if err := ValidateCanonicalPath(path); err != nil || mode.Perm() != 0o600 || mode&os.ModeType != 0 {
|
|
return ErrUnsafeFile
|
|
}
|
|
return replaceCanonicalRegular(path, contents)
|
|
}
|
|
|
|
// RemoveCanonicalPrivateRegular removes one existing private regular file without following a
|
|
// symlinked path component. It is intended only for rolling back a file this process published.
|
|
func RemoveCanonicalPrivateRegular(path string) error {
|
|
if err := ValidatePrivateRegular(path); err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
return removeCanonicalPrivateRegular(path)
|
|
}
|
|
|
|
// ClaimCanonicalPrivateRegular atomically creates a second, explicit private hard link to one
|
|
// existing record. It is used only for digest-named, single-use OIDC state claims.
|
|
func ClaimCanonicalPrivateRegular(source, claim string) (bool, error) {
|
|
if err := validateClaimPaths(source, claim); err != nil {
|
|
return false, ErrUnsafeFile
|
|
}
|
|
return claimCanonicalPrivateRegular(source, claim)
|
|
}
|
|
|
|
// ReadCanonicalPrivateClaim reads a verified two-link source/claim pair. found=false means the
|
|
// state has already been consumed or a winning process is between its two removal steps.
|
|
func ReadCanonicalPrivateClaim(source, claim string, maximum int64) ([]byte, bool, error) {
|
|
if maximum < 0 || maximum == int64(^uint64(0)>>1) || validateClaimPaths(source, claim) != nil {
|
|
return nil, false, ErrUnsafeFile
|
|
}
|
|
return readCanonicalPrivateClaim(source, claim, maximum)
|
|
}
|
|
|
|
// RemoveCanonicalPrivateClaim removes exactly a verified two-link source/claim pair.
|
|
func RemoveCanonicalPrivateClaim(source, claim string) (bool, error) {
|
|
if err := validateClaimPaths(source, claim); err != nil {
|
|
return false, ErrUnsafeFile
|
|
}
|
|
return removeCanonicalPrivateClaim(source, claim)
|
|
}
|
|
|
|
func validateClaimPaths(source, claim string) error {
|
|
if ValidateCanonicalPath(source) != nil || ValidateCanonicalPath(claim) != nil || filepath.Dir(source) != filepath.Dir(claim) {
|
|
return ErrUnsafeFile
|
|
}
|
|
if err := ValidatePrivateDirectory(filepath.Dir(source)); err != nil {
|
|
return ErrUnsafeFile
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomTemporaryName() (string, error) {
|
|
bytes := make([]byte, 16)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return ".tht-auth-" + hex.EncodeToString(bytes) + ".tmp", nil
|
|
}
|