aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/go/build/deps_test.go1
-rw-r--r--src/internal/safefilepath/path.go21
-rw-r--r--src/internal/safefilepath/path_other.go23
-rw-r--r--src/internal/safefilepath/path_test.go88
-rw-r--r--src/internal/safefilepath/path_windows.go95
-rw-r--r--src/net/http/fs.go8
-rw-r--r--src/net/http/fs_test.go28
-rw-r--r--src/os/file.go36
-rw-r--r--src/os/os_test.go38
9 files changed, 328 insertions, 10 deletions
diff --git a/src/go/build/deps_test.go b/src/go/build/deps_test.go
index 72465659dc..20a3d0fb86 100644
--- a/src/go/build/deps_test.go
+++ b/src/go/build/deps_test.go
@@ -168,6 +168,7 @@ var depsRules = `
io/fs
< internal/testlog
< internal/poll
+ < internal/safefilepath
< os
< os/signal;
diff --git a/src/internal/safefilepath/path.go b/src/internal/safefilepath/path.go
new file mode 100644
index 0000000000..0f0a270c30
--- /dev/null
+++ b/src/internal/safefilepath/path.go
@@ -0,0 +1,21 @@
+// Copyright 2022 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.
+
+// Package safefilepath manipulates operating-system file paths.
+package safefilepath
+
+import (
+ "errors"
+)
+
+var errInvalidPath = errors.New("invalid path")
+
+// FromFS converts a slash-separated path into an operating-system path.
+//
+// FromFS returns an error if the path cannot be represented by the operating
+// system. For example, paths containing '\' and ':' characters are rejected
+// on Windows.
+func FromFS(path string) (string, error) {
+ return fromFS(path)
+}
diff --git a/src/internal/safefilepath/path_other.go b/src/internal/safefilepath/path_other.go
new file mode 100644
index 0000000000..f93da18680
--- /dev/null
+++ b/src/internal/safefilepath/path_other.go
@@ -0,0 +1,23 @@
+// Copyright 2022 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 safefilepath
+
+import "runtime"
+
+func fromFS(path string) (string, error) {
+ if runtime.GOOS == "plan9" {
+ if len(path) > 0 && path[0] == '#' {
+ return path, errInvalidPath
+ }
+ }
+ for i := range path {
+ if path[i] == 0 {
+ return "", errInvalidPath
+ }
+ }
+ return path, nil
+}
diff --git a/src/internal/safefilepath/path_test.go b/src/internal/safefilepath/path_test.go
new file mode 100644
index 0000000000..dc662c18b3
--- /dev/null
+++ b/src/internal/safefilepath/path_test.go
@@ -0,0 +1,88 @@
+// Copyright 2022 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.
+
+package safefilepath_test
+
+import (
+ "internal/safefilepath"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+type PathTest struct {
+ path, result string
+}
+
+const invalid = ""
+
+var fspathtests = []PathTest{
+ {".", "."},
+ {"/a/b/c", "/a/b/c"},
+ {"a\x00b", invalid},
+}
+
+var winreservedpathtests = []PathTest{
+ {`a\b`, `a\b`},
+ {`a:b`, `a:b`},
+ {`a/b:c`, `a/b:c`},
+ {`NUL`, `NUL`},
+ {`./com1`, `./com1`},
+ {`a/nul/b`, `a/nul/b`},
+}
+
+// Whether a reserved name with an extension is reserved or not varies by
+// Windows version.
+var winreservedextpathtests = []PathTest{
+ {"nul.txt", "nul.txt"},
+ {"a/nul.txt/b", "a/nul.txt/b"},
+}
+
+var plan9reservedpathtests = []PathTest{
+ {`#c`, `#c`},
+}
+
+func TestFromFS(t *testing.T) {
+ switch runtime.GOOS {
+ case "windows":
+ if canWriteFile(t, "NUL") {
+ t.Errorf("can unexpectedly write a file named NUL on Windows")
+ }
+ if canWriteFile(t, "nul.txt") {
+ fspathtests = append(fspathtests, winreservedextpathtests...)
+ } else {
+ winreservedpathtests = append(winreservedpathtests, winreservedextpathtests...)
+ }
+ for i := range winreservedpathtests {
+ winreservedpathtests[i].result = invalid
+ }
+ for i := range fspathtests {
+ fspathtests[i].result = filepath.FromSlash(fspathtests[i].result)
+ }
+ case "plan9":
+ for i := range plan9reservedpathtests {
+ plan9reservedpathtests[i].result = invalid
+ }
+ }
+ tests := fspathtests
+ tests = append(tests, winreservedpathtests...)
+ tests = append(tests, plan9reservedpathtests...)
+ for _, test := range tests {
+ got, err := safefilepath.FromFS(test.path)
+ if (got == "") != (err != nil) {
+ t.Errorf(`FromFS(%q) = %q, %v; want "" only if err != nil`, test.path, got, err)
+ }
+ if got != test.result {
+ t.Errorf("FromFS(%q) = %q, %v; want %q", test.path, got, err, test.result)
+ }
+ }
+}
+
+func canWriteFile(t *testing.T, name string) bool {
+ path := filepath.Join(t.TempDir(), name)
+ os.WriteFile(path, []byte("ok"), 0666)
+ b, _ := os.ReadFile(path)
+ return string(b) == "ok"
+}
diff --git a/src/internal/safefilepath/path_windows.go b/src/internal/safefilepath/path_windows.go
new file mode 100644
index 0000000000..909c150edc
--- /dev/null
+++ b/src/internal/safefilepath/path_windows.go
@@ -0,0 +1,95 @@
+// Copyright 2022 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.
+
+package safefilepath
+
+import (
+ "syscall"
+ "unicode/utf8"
+)
+
+func fromFS(path string) (string, error) {
+ if !utf8.ValidString(path) {
+ return "", errInvalidPath
+ }
+ for len(path) > 1 && path[0] == '/' && path[1] == '/' {
+ path = path[1:]
+ }
+ containsSlash := false
+ for p := path; p != ""; {
+ // Find the next path element.
+ i := 0
+ dot := -1
+ for i < len(p) && p[i] != '/' {
+ switch p[i] {
+ case 0, '\\', ':':
+ return "", errInvalidPath
+ case '.':
+ if dot < 0 {
+ dot = i
+ }
+ }
+ i++
+ }
+ part := p[:i]
+ if i < len(p) {
+ containsSlash = true
+ p = p[i+1:]
+ } else {
+ p = ""
+ }
+ // Trim the extension and look for a reserved name.
+ base := part
+ if dot >= 0 {
+ base = part[:dot]
+ }
+ if isReservedName(base) {
+ if dot < 0 {
+ return "", errInvalidPath
+ }
+ // The path element is a reserved name with an extension.
+ // Some Windows versions consider this a reserved name,
+ // while others do not. Use FullPath to see if the name is
+ // reserved.
+ if p, _ := syscall.FullPath(part); len(p) >= 4 && p[:4] == `\\.\` {
+ return "", errInvalidPath
+ }
+ }
+ }
+ if containsSlash {
+ // We can't depend on strings, so substitute \ for / manually.
+ buf := []byte(path)
+ for i, b := range buf {
+ if b == '/' {
+ buf[i] = '\\'
+ }
+ }
+ path = string(buf)
+ }
+ return path, nil
+}
+
+// isReservedName reports if name is a Windows reserved device name.
+// It does not detect names with an extension, which are also reserved on some Windows versions.
+//
+// For details, search for PRN in
+// https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file.
+func isReservedName(name string) bool {
+ if 3 <= len(name) && len(name) <= 4 {
+ switch string([]byte{toUpper(name[0]), toUpper(name[1]), toUpper(name[2])}) {
+ case "CON", "PRN", "AUX", "NUL":
+ return len(name) == 3
+ case "COM", "LPT":
+ return len(name) == 4 && '1' <= name[3] && name[3] <= '9'
+ }
+ }
+ return false
+}
+
+func toUpper(c byte) byte {
+ if 'a' <= c && c <= 'z' {
+ return c - ('a' - 'A')
+ }
+ return c
+}
diff --git a/src/net/http/fs.go b/src/net/http/fs.go
index 6caee9ed93..583203043f 100644
--- a/src/net/http/fs.go
+++ b/src/net/http/fs.go
@@ -9,6 +9,7 @@ package http
import (
"errors"
"fmt"
+ "internal/safefilepath"
"io"
"io/fs"
"mime"
@@ -69,14 +70,15 @@ func mapOpenError(originalErr error, name string, sep rune, stat func(string) (f
// Open implements FileSystem using os.Open, opening files for reading rooted
// and relative to the directory d.
func (d Dir) Open(name string) (File, error) {
- if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
- return nil, errors.New("http: invalid character in file path")
+ path, err := safefilepath.FromFS(path.Clean("/" + name))
+ if err != nil {
+ return nil, errors.New("http: invalid or unsafe file path")
}
dir := string(d)
if dir == "" {
dir = "."
}
- fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
+ fullName := filepath.Join(dir, path)
f, err := os.Open(fullName)
if err != nil {
return nil, mapOpenError(err, fullName, filepath.Separator, os.Stat)
diff --git a/src/net/http/fs_test.go b/src/net/http/fs_test.go
index d627dfd4be..323360d550 100644
--- a/src/net/http/fs_test.go
+++ b/src/net/http/fs_test.go
@@ -648,6 +648,34 @@ func TestFileServerZeroByte(t *testing.T) {
}
}
+func TestFileServerNamesEscape(t *testing.T) {
+ t.Run("h1", func(t *testing.T) {
+ testFileServerNamesEscape(t, h1Mode)
+ })
+ t.Run("h2", func(t *testing.T) {
+ testFileServerNamesEscape(t, h2Mode)
+ })
+}
+func testFileServerNamesEscape(t *testing.T, h2 bool) {
+ defer afterTest(t)
+ ts := newClientServerTest(t, h2, FileServer(Dir("testdata"))).ts
+ defer ts.Close()
+ for _, path := range []string{
+ "/../testdata/file",
+ "/NUL", // don't read from device files on Windows
+ } {
+ res, err := ts.Client().Get(ts.URL + path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode < 400 || res.StatusCode > 599 {
+ t.Errorf("Get(%q): got status %v, want 4xx or 5xx", path, res.StatusCode)
+ }
+
+ }
+}
+
type fakeFileInfo struct {
dir bool
basename string
diff --git a/src/os/file.go b/src/os/file.go
index 2823128554..8afc6e3540 100644
--- a/src/os/file.go
+++ b/src/os/file.go
@@ -37,12 +37,12 @@
// Note: The maximum number of concurrent operations on a File may be limited by
// the OS or the system. The number should be high, but exceeding it may degrade
// performance or cause other issues.
-//
package os
import (
"errors"
"internal/poll"
+ "internal/safefilepath"
"internal/testlog"
"internal/unsafeheader"
"io"
@@ -623,6 +623,8 @@ func isWindowsNulName(name string) bool {
// the /prefix tree, then using DirFS does not stop the access any more than using
// os.Open does. DirFS is therefore not a general substitute for a chroot-style security
// mechanism when the directory tree contains arbitrary content.
+//
+// The directory dir must not be "".
func DirFS(dir string) fs.FS {
return dirFS(dir)
}
@@ -641,10 +643,11 @@ func containsAny(s, chars string) bool {
type dirFS string
func (dir dirFS) Open(name string) (fs.File, error) {
- if !fs.ValidPath(name) || runtime.GOOS == "windows" && containsAny(name, `\:`) {
- return nil, &PathError{Op: "open", Path: name, Err: ErrInvalid}
+ fullname, err := dir.join(name)
+ if err != nil {
+ return nil, &PathError{Op: "stat", Path: name, Err: err}
}
- f, err := Open(string(dir) + "/" + name)
+ f, err := Open(fullname)
if err != nil {
return nil, err // nil fs.File
}
@@ -652,16 +655,35 @@ func (dir dirFS) Open(name string) (fs.File, error) {
}
func (dir dirFS) Stat(name string) (fs.FileInfo, error) {
- if !fs.ValidPath(name) || runtime.GOOS == "windows" && containsAny(name, `\:`) {
- return nil, &PathError{Op: "stat", Path: name, Err: ErrInvalid}
+ fullname, err := dir.join(name)
+ if err != nil {
+ return nil, &PathError{Op: "stat", Path: name, Err: err}
}
- f, err := Stat(string(dir) + "/" + name)
+ f, err := Stat(fullname)
if err != nil {
return nil, err
}
return f, nil
}
+// join returns the path for name in dir.
+func (dir dirFS) join(name string) (string, error) {
+ if dir == "" {
+ return "", errors.New("os: DirFS with empty root")
+ }
+ if !fs.ValidPath(name) {
+ return "", ErrInvalid
+ }
+ name, err := safefilepath.FromFS(name)
+ if err != nil {
+ return "", ErrInvalid
+ }
+ if IsPathSeparator(dir[len(dir)-1]) {
+ return string(dir) + name, nil
+ }
+ return string(dir) + string(PathSeparator) + name, nil
+}
+
// ReadFile reads the named file and returns the contents.
// A successful call returns err == nil, not err == EOF.
// Because ReadFile reads the whole file, it does not treat an EOF from Read
diff --git a/src/os/os_test.go b/src/os/os_test.go
index 63427deb6e..4124be13cc 100644
--- a/src/os/os_test.go
+++ b/src/os/os_test.go
@@ -2696,6 +2696,44 @@ func TestDirFS(t *testing.T) {
if err == nil {
t.Fatalf(`Open testdata\dirfs succeeded`)
}
+
+ // Test that Open does not open Windows device files.
+ _, err = d.Open(`NUL`)
+ if err == nil {
+ t.Errorf(`Open NUL succeeded`)
+ }
+}
+
+func TestDirFSRootDir(t *testing.T) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cwd = cwd[len(filepath.VolumeName(cwd)):] // trim volume prefix (C:) on Windows
+ cwd = filepath.ToSlash(cwd) // convert \ to /
+ cwd = strings.TrimPrefix(cwd, "/") // trim leading /
+
+ // Test that Open can open a path starting at /.
+ d := DirFS("/")
+ f, err := d.Open(cwd + "/testdata/dirfs/a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+}
+
+func TestDirFSEmptyDir(t *testing.T) {
+ d := DirFS("")
+ cwd, _ := os.Getwd()
+ for _, path := range []string{
+ "testdata/dirfs/a", // not DirFS(".")
+ filepath.ToSlash(cwd) + "/testdata/dirfs/a", // not DirFS("/")
+ } {
+ _, err := d.Open(path)
+ if err == nil {
+ t.Fatalf(`DirFS("").Open(%q) succeeded`, path)
+ }
+ }
}
func TestDirFSPathsValid(t *testing.T) {