| Commit-Queue | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Code-Review | +2 |
println(f.name, de.Name(), err)Accidentally added?
if typ != ^FileMode(0) && !parent.inRoot {This is unrelated to your CL, but I'm not sure I understand why `typ != ^FileMode(0)` is here.
Independent of your `!parent.inRoot`, it looks like the intent is to say "lazy lstat is okay to do as long as the `typ` bits are not all set to `1`". However, I don't see any scenario where a `FileMode` bits would all be set to `1`.
Anecdotally, removing this check does not cause any test to fail for me too.
// File.Readdirnames, returningm []stringtypo?
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Code-Review | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Auto-Submit | +1 |
| Commit-Queue | +1 |
println(f.name, de.Name(), err)Damien NeilAccidentally added?
Oops, fixed.
This is unrelated to your CL, but I'm not sure I understand why `typ != ^FileMode(0)` is here.
Independent of your `!parent.inRoot`, it looks like the intent is to say "lazy lstat is okay to do as long as the `typ` bits are not all set to `1`". However, I don't see any scenario where a `FileMode` bits would all be set to `1`.
Anecdotally, removing this check does not cause any test to fail for me too.
I think the typ bits are theoretically all set to 1 if we can't determine the file mode at all for some reason. For example, in dirent_linux.go we return `^FileMode(0)` if the type byte in the kernel-filled dirent buffer is something we don't recognize.
I don't know how to make this happen in practice; I think it may be purely defensive.
// File.Readdirnames, returningm []stringDamien Neiltypo?
typo!
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Commit-Queue | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
2 is the latest approved patch-set.
The change was submitted with unreviewed changes in the following files:
```
The name of the file: src/os/root_test.go
Insertions: 1, Deletions: 1.
@@ -2046,7 +2046,7 @@
}
checkFileInfo(t, fileinfos[0])
})
- // File.Readdirnames, returningm []string
+ // File.Readdirnames, returning []string
test("Readdirnames", func(t *testing.T, subdir *os.File) {
names, err := subdir.Readdirnames(-1)
if err != nil {
```
```
The name of the file: src/os/dir_darwin.go
Insertions: 0, Deletions: 1.
@@ -89,7 +89,6 @@
names = append(names, string(name))
} else if mode == readdirDirEntry {
de, err := newUnixDirent(f, string(name), dtToType(dirent.Type))
- println(f.name, de.Name(), err)
if IsNotExist(err) {
// File disappeared between readdir and stat.
// Treat as if it didn't exist.
```
os: avoid escape from Root via ReadDir or Readdir
When reading the contents of a directory using
File.ReadDir or File.Readdir, the os.FileInfo was
populated on Unix platforms using lstat.
This lstat call is vulnerable to a TOCTOU race
and could escape the root.
For example:
- Open the directory "dir" within a Root.
This directory contains a file named "file".
- Use File.ReadDir to list the contents of "dir",
receiving a os.DirEntry for "dir/file".
- Replace "dir" with a symlink to "/etc".
- Use DirEntry.Info to retrieve the FileInfo for "dir/file".
This FileInfo contains information on "/etc/file" instead.
This escape permits identifying the presence or absence of
files outside a Root, as well as retreiving stat metadata
(size, mode, modification time, etc.) for files outside a Root.
This escape does not permit reading or writing to files
outside a Root.
Fixes #77827
Fixes CVE-2026-27139
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
[release-branch.go1.25] os: avoid escape from Root via ReadDir or Readdir
When reading the contents of a directory using
File.ReadDir or File.Readdir, the os.FileInfo was
populated on Unix platforms using lstat.
This lstat call is vulnerable to a TOCTOU race
and could escape the root.
For example:
- Open the directory "dir" within a Root.
This directory contains a file named "file".
- Use File.ReadDir to list the contents of "dir",
receiving a os.DirEntry for "dir/file".
- Replace "dir" with a symlink to "/etc".
- Use DirEntry.Info to retrieve the FileInfo for "dir/file".
This FileInfo contains information on "/etc/file" instead.
This escape permits identifying the presence or absence of
files outside a Root, as well as retreiving stat metadata
(size, mode, modification time, etc.) for files outside a Root.
This escape does not permit reading or writing to files
outside a Root.
For #77827
Fixes #77833
Fixes CVE-2026-27139
Change-Id: I40004f830c588e516aff8ee593d630d36a6a6964
Reviewed-on: https://go-review.googlesource.com/c/go/+/749480
LUCI-TryBot-Result: Go LUCI <golang...@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Nicholas Husin <hu...@google.com>
Reviewed-by: Nicholas Husin <n...@golang.org>
Auto-Submit: Damien Neil <dn...@google.com>
(cherry picked from commit 657ed934e85dc575aad51356c4b437961e7c1313)
diff --git a/src/internal/poll/fstatat_unix.go b/src/internal/poll/fstatat_unix.go
new file mode 100644
index 0000000..cde8551
--- /dev/null
+++ b/src/internal/poll/fstatat_unix.go
@@ -0,0 +1,22 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build unix || wasip1
+
+package poll
+
+import (
+ "internal/syscall/unix"
+ "syscall"
+)
+
+func (fd *FD) Fstatat(name string, s *syscall.Stat_t, flags int) error {
+ if err := fd.incref(); err != nil {
+ return err
+ }
+ defer fd.decref()
+ return ignoringEINTR(func() error {
+ return unix.Fstatat(fd.Sysfd, name, s, flags)
+ })
+}
diff --git a/src/os/dir_darwin.go b/src/os/dir_darwin.go
index 91b67d8..b6ebca7 100644
--- a/src/os/dir_darwin.go
+++ b/src/os/dir_darwin.go
@@ -88,7 +88,7 @@
if mode == readdirName {
names = append(names, string(name))
} else if mode == readdirDirEntry {
- de, err := newUnixDirent(f.name, string(name), dtToType(dirent.Type))
+ de, err := newUnixDirent(f, string(name), dtToType(dirent.Type))
if IsNotExist(err) {
// File disappeared between readdir and stat.
// Treat as if it didn't exist.
@@ -99,7 +99,7 @@
}
dirents = append(dirents, de)
} else {
- info, err := lstat(f.name + "/" + string(name))
+ info, err := f.lstatat(string(name))
if IsNotExist(err) {
// File disappeared between readdir + stat.
// Treat as if it didn't exist.
diff --git a/src/os/dir_unix.go b/src/os/dir_unix.go
index 87df312..8f1aabe 100644
--- a/src/os/dir_unix.go
+++ b/src/os/dir_unix.go
@@ -138,7 +138,7 @@
if mode == readdirName {
names = append(names, string(name))
} else if mode == readdirDirEntry {
- de, err := newUnixDirent(f.name, string(name), direntType(rec))
+ de, err := newUnixDirent(f, string(name), direntType(rec))
if IsNotExist(err) {
// File disappeared between readdir and stat.
// Treat as if it didn't exist.
@@ -149,7 +149,7 @@
}
dirents = append(dirents, de)
} else {
- info, err := lstat(f.name + "/" + string(name))
+ info, err := f.lstatat(string(name))
if IsNotExist(err) {
// File disappeared between readdir + stat.
// Treat as if it didn't exist.
diff --git a/src/os/export_test.go b/src/os/export_test.go
index 93b1089..bea38c9 100644
--- a/src/os/export_test.go
+++ b/src/os/export_test.go
@@ -7,7 +7,6 @@
// Export for testing.
var Atime = atime
-var LstatP = &lstat
var ErrWriteAtInAppendMode = errWriteAtInAppendMode
var ErrPatternHasSeparator = errPatternHasSeparator
@@ -16,3 +15,16 @@
}
var ExportReadFileContents = readFileContents
+
+// cleanuper stands in for *testing.T, since we can't import testing in os.
+type cleanuper interface {
+ Cleanup(func())
+}
+
+func SetStatHook(t cleanuper, f func(f *File, name string) (FileInfo, error)) {
+ oldstathook := stathook
+ t.Cleanup(func() {
+ stathook = oldstathook
+ })
+ stathook = f
+}
diff --git a/src/os/file.go b/src/os/file.go
index 66269c1..8085724 100644
--- a/src/os/file.go
+++ b/src/os/file.go
@@ -428,9 +428,6 @@
return openDirNolog(name)
}
-// lstat is overridden in tests.
-var lstat = Lstat
-
// Rename renames (moves) oldpath to newpath.
// If newpath already exists and is not a directory, Rename replaces it.
// If newpath already exists and is a directory, Rename returns an error.
diff --git a/src/os/file_unix.go b/src/os/file_unix.go
index 2074df7..6f9cab78 100644
--- a/src/os/file_unix.go
+++ b/src/os/file_unix.go
@@ -63,6 +63,7 @@
nonblock bool // whether we set nonblocking mode
stdoutOrErr bool // whether this is stdout or stderr
appendMode bool // whether file is opened for appending
+ inRoot bool // whether file is opened in a Root
}
// fd is the Unix implementation of Fd.
@@ -458,24 +459,27 @@
if d.info != nil {
return d.info, nil
}
- return lstat(d.parent + "/" + d.name)
+ return Lstat(d.parent + "/" + d.name)
}
func (d *unixDirent) String() string {
return fs.FormatDirEntry(d)
}
-func newUnixDirent(parent, name string, typ FileMode) (DirEntry, error) {
+func newUnixDirent(parent *File, name string, typ FileMode) (DirEntry, error) {
ude := &unixDirent{
- parent: parent,
+ parent: parent.name,
name: name,
typ: typ,
}
- if typ != ^FileMode(0) {
+ // When the parent file was opened in a Root,
+ // we cannot use a lazy lstat to load the FileInfo.
+ // Use lstatat here.
+ if typ != ^FileMode(0) && !parent.inRoot {
return ude, nil
}
- info, err := lstat(parent + "/" + name)
+ info, err := parent.lstatat(name)
if err != nil {
return nil, err
}
diff --git a/src/os/os_test.go b/src/os/os_test.go
index 9f6eb13e..13db353 100644
--- a/src/os/os_test.go
+++ b/src/os/os_test.go
@@ -767,13 +767,12 @@
}
var xerr error // error to return for x
- *LstatP = func(path string) (FileInfo, error) {
+ SetStatHook(t, func(f *File, path string) (FileInfo, error) {
if xerr != nil && strings.HasSuffix(path, "x") {
return nil, xerr
}
- return Lstat(path)
- }
- defer func() { *LstatP = Lstat }()
+ return nil, nil
+ })
dir := t.TempDir()
touch(t, filepath.Join(dir, "good1"))
diff --git a/src/os/os_unix_test.go b/src/os/os_unix_test.go
index 41feaf7..6b55e09 100644
--- a/src/os/os_unix_test.go
+++ b/src/os/os_unix_test.go
@@ -196,15 +196,13 @@
// Issue 16919: Readdir must return a non-empty slice or an error.
func TestReaddirRemoveRace(t *testing.T) {
- oldStat := *LstatP
- defer func() { *LstatP = oldStat }()
- *LstatP = func(name string) (FileInfo, error) {
+ SetStatHook(t, func(f *File, name string) (FileInfo, error) {
if strings.HasSuffix(name, "some-file") {
// Act like it's been deleted.
return nil, ErrNotExist
}
- return oldStat(name)
- }
+ return nil, nil
+ })
dir := t.TempDir()
if err := WriteFile(filepath.Join(dir, "some-file"), []byte("hello"), 0644); err != nil {
t.Fatal(err)
diff --git a/src/os/root_test.go b/src/os/root_test.go
index f9fbd11..71ce5e5 100644
--- a/src/os/root_test.go
+++ b/src/os/root_test.go
@@ -1952,3 +1952,108 @@
t.Errorf(`root.OpenRoot("dir").Name() = %q, want %q`, got, want)
}
}
+
+// TestRootNoLstat verifies that we do not use lstat (possibly escaping the root)
+// when reading directories in a Root.
+func TestRootNoLstat(t *testing.T) {
+ if runtime.GOARCH == "wasm" {
+ t.Skip("wasm lacks fstatat")
+ }
+
+ dir := makefs(t, []string{
+ "subdir/",
+ })
+ const size = 42
+ contents := strings.Repeat("x", size)
+ if err := os.WriteFile(dir+"/subdir/file", []byte(contents), 0666); err != nil {
+ t.Fatal(err)
+ }
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+
+ test := func(name string, fn func(t *testing.T, f *os.File)) {
+ t.Run(name, func(t *testing.T) {
+ os.SetStatHook(t, func(f *os.File, name string) (os.FileInfo, error) {
+ if f == nil {
+ t.Errorf("unexpected Lstat(%q)", name)
+ }
+ return nil, nil
+ })
+ f, err := root.Open("subdir")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ fn(t, f)
+ })
+ }
+
+ checkFileInfo := func(t *testing.T, fi fs.FileInfo) {
+ t.Helper()
+ if got, want := fi.Name(), "file"; got != want {
+ t.Errorf("FileInfo.Name() = %q, want %q", got, want)
+ }
+ if got, want := fi.Size(), int64(size); got != want {
+ t.Errorf("FileInfo.Size() = %v, want %v", got, want)
+ }
+ }
+ checkDirEntry := func(t *testing.T, d fs.DirEntry) {
+ t.Helper()
+ if got, want := d.Name(), "file"; got != want {
+ t.Errorf("DirEntry.Name() = %q, want %q", got, want)
+ }
+ if got, want := d.IsDir(), false; got != want {
+ t.Errorf("DirEntry.IsDir() = %v, want %v", got, want)
+ }
+ fi, err := d.Info()
+ if err != nil {
+ t.Fatalf("DirEntry.Info() = _, %v", err)
+ }
+ checkFileInfo(t, fi)
+ }
+
+ test("Stat", func(t *testing.T, subdir *os.File) {
+ fi, err := subdir.Stat()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !fi.IsDir() {
+ t.Fatalf(`Open("subdir").Stat().IsDir() = false, want true`)
+ }
+ })
+ // File.ReadDir, returning []DirEntry
+ test("ReadDirEntry", func(t *testing.T, subdir *os.File) {
+ dirents, err := subdir.ReadDir(-1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(dirents) != 1 {
+ t.Fatalf(`Open("subdir").ReadDir(-1) = {%v}, want {file}`, dirents)
+ }
+ checkDirEntry(t, dirents[0])
+ })
+ // File.Readdir, returning []FileInfo
+ test("ReadFileInfo", func(t *testing.T, subdir *os.File) {
+ fileinfos, err := subdir.Readdir(-1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fileinfos) != 1 {
+ t.Fatalf(`Open("subdir").Readdir(-1) = {%v}, want {file}`, fileinfos)
+ }
+ checkFileInfo(t, fileinfos[0])
+ })
+ // File.Readdirnames, returning []string
+ test("Readdirnames", func(t *testing.T, subdir *os.File) {
+ names, err := subdir.Readdirnames(-1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := names, []string{"file"}; !slices.Equal(got, want) {
+ t.Fatalf(`Open("subdir").Readdirnames(-1) = %q, want %q`, got, want)
+ }
+ })
+}
diff --git a/src/os/root_unix.go b/src/os/root_unix.go
index c891e81..885a835 100644
--- a/src/os/root_unix.go
+++ b/src/os/root_unix.go
@@ -104,6 +104,7 @@
return nil, &PathError{Op: "openat", Path: name, Err: err}
}
f := newFile(fd, joinPath(root.Name(), name), kindOpenFile, unix.HasNonblockFlag(flag))
+ f.inRoot = true
return f, nil
}
diff --git a/src/os/stat.go b/src/os/stat.go
index 50acb6d..5ef731f 100644
--- a/src/os/stat.go
+++ b/src/os/stat.go
@@ -25,3 +25,6 @@
testlog.Stat(name)
return lstatNolog(name)
}
+
+// stathook is set in tests
+var stathook func(f *File, name string) (FileInfo, error)
diff --git a/src/os/statat.go b/src/os/statat.go
new file mode 100644
index 0000000..d460fe2
--- /dev/null
+++ b/src/os/statat.go
@@ -0,0 +1,24 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build !windows
+
+package os
+
+import (
+ "internal/testlog"
+)
+
+func (f *File) lstatat(name string) (FileInfo, error) {
+ if stathook != nil {
+ fi, err := stathook(f, name)
+ if fi != nil || err != nil {
+ return fi, err
+ }
+ }
+ if log := testlog.Logger(); log != nil {
+ log.Stat(joinPath(f.Name(), name))
+ }
+ return f.lstatatNolog(name)
+}
diff --git a/src/os/statat_other.go b/src/os/statat_other.go
new file mode 100644
index 0000000..673ae21
--- /dev/null
+++ b/src/os/statat_other.go
@@ -0,0 +1,12 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build (js && wasm) || plan9
+
+package os
+
+func (f *File) lstatatNolog(name string) (FileInfo, error) {
+ // These platforms don't have fstatat, so use stat instead.
+ return Lstat(f.name + "/" + name)
+}
diff --git a/src/os/statat_unix.go b/src/os/statat_unix.go
new file mode 100644
index 0000000..80f89f9
--- /dev/null
+++ b/src/os/statat_unix.go
@@ -0,0 +1,20 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build aix || darwin || dragonfly || freebsd || wasip1 || linux || netbsd || openbsd || solaris
+
+package os
+
+import (
+ "internal/syscall/unix"
+)
+
+func (f *File) lstatatNolog(name string) (FileInfo, error) {
+ var fs fileStat
+ if err := f.pfd.Fstatat(name, &fs.sys, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+ return nil, f.wrapErr("fstatat", err)
+ }
+ fillFileStatFromSys(&fs, name)
+ return &fs, nil
+}
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Commit-Queue | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
[release-branch.go1.26] os: avoid escape from Root via ReadDir or Readdir
When reading the contents of a directory using
File.ReadDir or File.Readdir, the os.FileInfo was
populated on Unix platforms using lstat.
This lstat call is vulnerable to a TOCTOU race
and could escape the root.
For example:
- Open the directory "dir" within a Root.
This directory contains a file named "file".
- Use File.ReadDir to list the contents of "dir",
receiving a os.DirEntry for "dir/file".
- Replace "dir" with a symlink to "/etc".
- Use DirEntry.Info to retrieve the FileInfo for "dir/file".
This FileInfo contains information on "/etc/file" instead.
This escape permits identifying the presence or absence of
files outside a Root, as well as retreiving stat metadata
(size, mode, modification time, etc.) for files outside a Root.
This escape does not permit reading or writing to files
outside a Root.
For #77827
Fixes #77834
index 47f4163..d95cf3a 100644| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |