Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/safepaths/absolute.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,12 @@ type PathTraversalError struct {
}

func (e PathTraversalError) Error() string {
if e.Base.path == "" {
path := filepath.Join(e.Elems...)
if len(e.Elems) == 1 {
path = e.Elems[0]
}
return fmt.Sprintf("path %q is not a local child path", path)
}
return fmt.Sprintf("joining %s and %s would be a traversal", e.Base, filepath.Join(e.Elems...))
}
9 changes: 9 additions & 0 deletions internal/safepaths/link_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package safepaths

import "os"

func isPathRedirect(_ *Root, _ *os.Root, _ string, info os.FileInfo) (bool, error) {
return info.Mode()&os.ModeSymlink != 0, nil
}
37 changes: 37 additions & 0 deletions internal/safepaths/link_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package safepaths

import (
"errors"
"os"
"syscall"
)

// os.Root intentionally refuses every final reparse point. Windows does not
// expose a supported handle-relative open that both follows non-link reparse
// data and atomically refuses a race to a name-surrogate redirect, so fail
// closed rather than re-resolving the pinned parent by path.
var errUnsupportedNonLinkReparse = errors.New("non-link reparse points cannot be safely opened beneath a rooted directory on Windows")

func isPathRedirect(owner *Root, root *os.Root, name string, info os.FileInfo) (bool, error) {
return classifyWindowsPathRedirect(info, func() error {
_, err := owner.readlink(root, name)
return err
})
}

func classifyWindowsPathRedirect(info os.FileInfo, readlink func() error) (bool, error) {
if info.Mode()&os.ModeSymlink != 0 {
return true, nil
}
data, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok || data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT == 0 {
return false, nil
}
if err := readlink(); err != nil {
if os.IsNotExist(err) {
return false, errUnsupportedNonLinkReparse
}
return false, err
}
return true, nil
}
172 changes: 172 additions & 0 deletions internal/safepaths/link_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package safepaths

import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReparsePointClassification(t *testing.T) {
t.Run("name surrogate redirects", func(t *testing.T) {
info := reparsePointInfo{mode: fs.ModeSymlink}
redirect, err := classifyWindowsPathRedirect(info, func() error {
return errors.New("should not be called")
})
require.NoError(t, err)
assert.True(t, redirect)
})
t.Run("mount point redirects", func(t *testing.T) {
info := reparsePointInfo{
mode: fs.ModeIrregular,
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
}
redirect, err := classifyWindowsPathRedirect(info, func() error { return nil })
require.NoError(t, err)
assert.True(t, redirect)
})
t.Run("non-link reparse point fails closed", func(t *testing.T) {
info := reparsePointInfo{
mode: fs.ModeDir,
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
}
redirect, err := classifyWindowsPathRedirect(info, func() error { return os.ErrNotExist })
assert.False(t, redirect)
require.ErrorIs(t, err, errUnsupportedNonLinkReparse)
})
t.Run("unexpected readlink error is returned", func(t *testing.T) {
info := reparsePointInfo{
mode: fs.ModeIrregular,
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
}

redirect, err := classifyWindowsPathRedirect(info, func() error {
return errors.New("readlink failed")
})
assert.False(t, redirect)
require.ErrorContains(t, err, "readlink failed")
})
}

func TestCreateReplaceFailsClosedForNonLinkReparse(t *testing.T) {
rootDir := t.TempDir()
path := filepath.Join(rootDir, "entry")
require.NoError(t, os.WriteFile(path, []byte("original"), 0o644))
before, err := os.Stat(path)
require.NoError(t, err)

root, err := OpenRoot(rootDir)
require.NoError(t, err)
defer root.Close()
root.lstat = func(*os.Root, string) (os.FileInfo, error) {
return reparsePointInfo{
mode: fs.ModeIrregular,
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
}, nil
}
root.readlink = func(*os.Root, string) (string, error) {
return "", os.ErrNotExist
}

file, err := root.Create("entry", 0o644, 0o755, true)
assert.Nil(t, file)
require.ErrorIs(t, err, errUnsupportedNonLinkReparse)
after, err := os.Stat(path)
require.NoError(t, err)
assert.True(t, os.SameFile(before, after))
content, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "original", string(content))
}

func TestRootRejectsJunction(t *testing.T) {
rootDir := t.TempDir()
target := t.TempDir()
junction := filepath.Join(rootDir, "junction")
if output, err := exec.Command("cmd", "/c", "mklink", "/J", junction, target).CombinedOutput(); err != nil {
t.Skipf("junctions unavailable: %v: %s", err, output)
}

root, err := OpenRoot(rootDir)
require.NoError(t, err)
defer root.Close()

err = root.WriteFile(filepath.Join("junction", "file.txt"), []byte("content"), 0o644, 0o755, false)
require.ErrorContains(t, err, "symbolic link")
assert.NoFileExists(t, filepath.Join(target, "file.txt"))

err = root.WriteFile("junction", []byte("content"), 0o644, 0o755, true)
require.Error(t, err)
info, err := os.Lstat(junction)
require.NoError(t, err)
assert.NotZero(t, info.Sys().(*syscall.Win32FileAttributeData).FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT)
}

func TestOpenFileRejectsWindowsTrailingSeparator(t *testing.T) {
tests := []struct {
name string
separator string
}{
{name: "backslash", separator: `\`},
{name: "slash", separator: `/`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "output") + tt.separator

file, err := OpenFile(path, 0o644, 0o755, false)
if file != nil {
_ = file.Close()
}
require.ErrorContains(t, err, "ends in a separator")
_, statErr := os.Lstat(filepath.Join(root, "output"))
require.True(t, os.IsNotExist(statErr))
})
}
}

func TestRootCreateRejectsWindowsTrailingSeparator(t *testing.T) {
tests := []struct {
name string
separator string
}{
{name: "backslash", separator: `\`},
{name: "slash", separator: `/`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rootDir := t.TempDir()
root, err := OpenRoot(rootDir)
require.NoError(t, err)
defer root.Close()

err = root.WriteFile("output"+tt.separator, []byte("content"), 0o644, 0o755, false)
require.ErrorContains(t, err, "ends in a separator")
_, statErr := os.Lstat(filepath.Join(rootDir, "output"))
require.True(t, os.IsNotExist(statErr))
assert.NoFileExists(t, filepath.Join(rootDir, "output", "output"))
})
}
}

type reparsePointInfo struct {
mode fs.FileMode
attributes uint32
}

func (reparsePointInfo) Name() string { return "reparse" }
func (reparsePointInfo) Size() int64 { return 0 }
func (i reparsePointInfo) Mode() fs.FileMode { return i.mode }
func (reparsePointInfo) ModTime() time.Time { return time.Time{} }
func (i reparsePointInfo) IsDir() bool { return i.mode.IsDir() }
func (i reparsePointInfo) Sys() any {
return &syscall.Win32FileAttributeData{FileAttributes: i.attributes}
}
13 changes: 13 additions & 0 deletions internal/safepaths/open_nonblock_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !unix

package safepaths

import "os"

func nonBlockingOpenFlag() int {
return 0
}

func clearNonblocking(*os.File) error {
return nil
}
16 changes: 16 additions & 0 deletions internal/safepaths/open_nonblock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build unix

package safepaths

import (
"os"
"syscall"
)

func nonBlockingOpenFlag() int {
return syscall.O_NONBLOCK
}

func clearNonblocking(file *os.File) error {
return syscall.SetNonblock(int(file.Fd()), false)
}
Loading
Loading