Skip to content

Commit fc4b137

Browse files
authored
Merge commit from fork
Harden filesystem writes against symlinks
2 parents c601748 + 8b97df3 commit fc4b137

29 files changed

Lines changed: 2692 additions & 219 deletions

‎internal/safepaths/absolute.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,12 @@ type PathTraversalError struct {
7070
}
7171

7272
func (e PathTraversalError) Error() string {
73+
if e.Base.path == "" {
74+
path := filepath.Join(e.Elems...)
75+
if len(e.Elems) == 1 {
76+
path = e.Elems[0]
77+
}
78+
return fmt.Sprintf("path %q is not a local child path", path)
79+
}
7380
return fmt.Sprintf("joining %s and %s would be a traversal", e.Base, filepath.Join(e.Elems...))
7481
}

‎internal/safepaths/link_other.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !windows
2+
3+
package safepaths
4+
5+
import "os"
6+
7+
func isPathRedirect(_ *Root, _ *os.Root, _ string, info os.FileInfo) (bool, error) {
8+
return info.Mode()&os.ModeSymlink != 0, nil
9+
}

‎internal/safepaths/link_windows.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package safepaths
2+
3+
import (
4+
"errors"
5+
"os"
6+
"syscall"
7+
)
8+
9+
// os.Root intentionally refuses every final reparse point. Windows does not
10+
// expose a supported handle-relative open that both follows non-link reparse
11+
// data and atomically refuses a race to a name-surrogate redirect, so fail
12+
// closed rather than re-resolving the pinned parent by path.
13+
var errUnsupportedNonLinkReparse = errors.New("non-link reparse points cannot be safely opened beneath a rooted directory on Windows")
14+
15+
func isPathRedirect(owner *Root, root *os.Root, name string, info os.FileInfo) (bool, error) {
16+
return classifyWindowsPathRedirect(info, func() error {
17+
_, err := owner.readlink(root, name)
18+
return err
19+
})
20+
}
21+
22+
func classifyWindowsPathRedirect(info os.FileInfo, readlink func() error) (bool, error) {
23+
if info.Mode()&os.ModeSymlink != 0 {
24+
return true, nil
25+
}
26+
data, ok := info.Sys().(*syscall.Win32FileAttributeData)
27+
if !ok || data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT == 0 {
28+
return false, nil
29+
}
30+
if err := readlink(); err != nil {
31+
if os.IsNotExist(err) {
32+
return false, errUnsupportedNonLinkReparse
33+
}
34+
return false, err
35+
}
36+
return true, nil
37+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package safepaths
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"syscall"
10+
"testing"
11+
"time"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestReparsePointClassification(t *testing.T) {
18+
t.Run("name surrogate redirects", func(t *testing.T) {
19+
info := reparsePointInfo{mode: fs.ModeSymlink}
20+
redirect, err := classifyWindowsPathRedirect(info, func() error {
21+
return errors.New("should not be called")
22+
})
23+
require.NoError(t, err)
24+
assert.True(t, redirect)
25+
})
26+
t.Run("mount point redirects", func(t *testing.T) {
27+
info := reparsePointInfo{
28+
mode: fs.ModeIrregular,
29+
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
30+
}
31+
redirect, err := classifyWindowsPathRedirect(info, func() error { return nil })
32+
require.NoError(t, err)
33+
assert.True(t, redirect)
34+
})
35+
t.Run("non-link reparse point fails closed", func(t *testing.T) {
36+
info := reparsePointInfo{
37+
mode: fs.ModeDir,
38+
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
39+
}
40+
redirect, err := classifyWindowsPathRedirect(info, func() error { return os.ErrNotExist })
41+
assert.False(t, redirect)
42+
require.ErrorIs(t, err, errUnsupportedNonLinkReparse)
43+
})
44+
t.Run("unexpected readlink error is returned", func(t *testing.T) {
45+
info := reparsePointInfo{
46+
mode: fs.ModeIrregular,
47+
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
48+
}
49+
50+
redirect, err := classifyWindowsPathRedirect(info, func() error {
51+
return errors.New("readlink failed")
52+
})
53+
assert.False(t, redirect)
54+
require.ErrorContains(t, err, "readlink failed")
55+
})
56+
}
57+
58+
func TestCreateReplaceFailsClosedForNonLinkReparse(t *testing.T) {
59+
rootDir := t.TempDir()
60+
path := filepath.Join(rootDir, "entry")
61+
require.NoError(t, os.WriteFile(path, []byte("original"), 0o644))
62+
before, err := os.Stat(path)
63+
require.NoError(t, err)
64+
65+
root, err := OpenRoot(rootDir)
66+
require.NoError(t, err)
67+
defer root.Close()
68+
root.lstat = func(*os.Root, string) (os.FileInfo, error) {
69+
return reparsePointInfo{
70+
mode: fs.ModeIrregular,
71+
attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT,
72+
}, nil
73+
}
74+
root.readlink = func(*os.Root, string) (string, error) {
75+
return "", os.ErrNotExist
76+
}
77+
78+
file, err := root.Create("entry", 0o644, 0o755, true)
79+
assert.Nil(t, file)
80+
require.ErrorIs(t, err, errUnsupportedNonLinkReparse)
81+
after, err := os.Stat(path)
82+
require.NoError(t, err)
83+
assert.True(t, os.SameFile(before, after))
84+
content, err := os.ReadFile(path)
85+
require.NoError(t, err)
86+
assert.Equal(t, "original", string(content))
87+
}
88+
89+
func TestRootRejectsJunction(t *testing.T) {
90+
rootDir := t.TempDir()
91+
target := t.TempDir()
92+
junction := filepath.Join(rootDir, "junction")
93+
if output, err := exec.Command("cmd", "/c", "mklink", "/J", junction, target).CombinedOutput(); err != nil {
94+
t.Skipf("junctions unavailable: %v: %s", err, output)
95+
}
96+
97+
root, err := OpenRoot(rootDir)
98+
require.NoError(t, err)
99+
defer root.Close()
100+
101+
err = root.WriteFile(filepath.Join("junction", "file.txt"), []byte("content"), 0o644, 0o755, false)
102+
require.ErrorContains(t, err, "symbolic link")
103+
assert.NoFileExists(t, filepath.Join(target, "file.txt"))
104+
105+
err = root.WriteFile("junction", []byte("content"), 0o644, 0o755, true)
106+
require.Error(t, err)
107+
info, err := os.Lstat(junction)
108+
require.NoError(t, err)
109+
assert.NotZero(t, info.Sys().(*syscall.Win32FileAttributeData).FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT)
110+
}
111+
112+
func TestOpenFileRejectsWindowsTrailingSeparator(t *testing.T) {
113+
tests := []struct {
114+
name string
115+
separator string
116+
}{
117+
{name: "backslash", separator: `\`},
118+
{name: "slash", separator: `/`},
119+
}
120+
for _, tt := range tests {
121+
t.Run(tt.name, func(t *testing.T) {
122+
root := t.TempDir()
123+
path := filepath.Join(root, "output") + tt.separator
124+
125+
file, err := OpenFile(path, 0o644, 0o755, false)
126+
if file != nil {
127+
_ = file.Close()
128+
}
129+
require.ErrorContains(t, err, "ends in a separator")
130+
_, statErr := os.Lstat(filepath.Join(root, "output"))
131+
require.True(t, os.IsNotExist(statErr))
132+
})
133+
}
134+
}
135+
136+
func TestRootCreateRejectsWindowsTrailingSeparator(t *testing.T) {
137+
tests := []struct {
138+
name string
139+
separator string
140+
}{
141+
{name: "backslash", separator: `\`},
142+
{name: "slash", separator: `/`},
143+
}
144+
for _, tt := range tests {
145+
t.Run(tt.name, func(t *testing.T) {
146+
rootDir := t.TempDir()
147+
root, err := OpenRoot(rootDir)
148+
require.NoError(t, err)
149+
defer root.Close()
150+
151+
err = root.WriteFile("output"+tt.separator, []byte("content"), 0o644, 0o755, false)
152+
require.ErrorContains(t, err, "ends in a separator")
153+
_, statErr := os.Lstat(filepath.Join(rootDir, "output"))
154+
require.True(t, os.IsNotExist(statErr))
155+
assert.NoFileExists(t, filepath.Join(rootDir, "output", "output"))
156+
})
157+
}
158+
}
159+
160+
type reparsePointInfo struct {
161+
mode fs.FileMode
162+
attributes uint32
163+
}
164+
165+
func (reparsePointInfo) Name() string { return "reparse" }
166+
func (reparsePointInfo) Size() int64 { return 0 }
167+
func (i reparsePointInfo) Mode() fs.FileMode { return i.mode }
168+
func (reparsePointInfo) ModTime() time.Time { return time.Time{} }
169+
func (i reparsePointInfo) IsDir() bool { return i.mode.IsDir() }
170+
func (i reparsePointInfo) Sys() any {
171+
return &syscall.Win32FileAttributeData{FileAttributes: i.attributes}
172+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build !unix
2+
3+
package safepaths
4+
5+
import "os"
6+
7+
func nonBlockingOpenFlag() int {
8+
return 0
9+
}
10+
11+
func clearNonblocking(*os.File) error {
12+
return nil
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build unix
2+
3+
package safepaths
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
func nonBlockingOpenFlag() int {
11+
return syscall.O_NONBLOCK
12+
}
13+
14+
func clearNonblocking(file *os.File) error {
15+
return syscall.SetNonblock(int(file.Fd()), false)
16+
}

0 commit comments

Comments
 (0)