mirror of
https://github.com/openziti/zrok.git
synced 2026-08-24 10:14:56 -05:00
fix for drive backend symlink traversal
This commit is contained in:
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.contexts
|
.contexts
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
/.claude/
|
.claude
|
||||||
|
.codex
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
*.db
|
*.db
|
||||||
/automated-release-build/
|
/automated-release-build/
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## v2.0.2
|
||||||
|
|
||||||
|
FIX: The `drive` backend mode WebDAV implementation now prevents symlink traversal outside the configured shared directory. `Stat`, `OpenFile`, `Mkdir`, `RemoveAll`, and `Rename` now reject symlinks that resolve outside the drive root while continuing to allow symlinks that resolve within that tree. This fixes GHSA-74m3-9qvm-rp9h.
|
||||||
|
|
||||||
## v2.0.1
|
## v2.0.1
|
||||||
|
|
||||||
FEATURE: Added several new `admin` API endpoints for interfacing with additional management controls: finding limit classes by label, finding applied/applying/removing limit classes from accounts, getting/setting skip interstitial status for an account (https://github.com/openziti/zrok/issues/1210)
|
FEATURE: Added several new `admin` API endpoints for interfacing with additional management controls: finding limit classes by label, finding applied/applying/removing limit classes from accounts, getting/setting skip interstitial status for an account (https://github.com/openziti/zrok/issues/1210)
|
||||||
|
|||||||
+180
-17
@@ -57,6 +57,28 @@ type File interface {
|
|||||||
io.Writer
|
io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type namedFileInfo struct {
|
||||||
|
os.FileInfo
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi namedFileInfo) Name() string {
|
||||||
|
return fi.name
|
||||||
|
}
|
||||||
|
|
||||||
|
type osDirFile struct {
|
||||||
|
*os.File
|
||||||
|
statName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osDirFile) Stat() (os.FileInfo, error) {
|
||||||
|
info, err := f.File.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return namedFileInfo{FileInfo: info, name: f.statName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type webdavFile struct {
|
type webdavFile struct {
|
||||||
File
|
File
|
||||||
name string
|
name string
|
||||||
@@ -130,54 +152,195 @@ func (d Dir) resolve(name string) string {
|
|||||||
return filepath.Join(dir, filepath.FromSlash(slashClean(name)))
|
return filepath.Join(dir, filepath.FromSlash(slashClean(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d Dir) rootPath() (string, error) {
|
||||||
|
dir := string(d)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
root, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.EvalSymlinks(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitVirtualPath(name string) []string {
|
||||||
|
switch name {
|
||||||
|
case "", ".", "/":
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name = strings.TrimPrefix(name, "/")
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(name, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinVirtualPath(base string, segments []string) string {
|
||||||
|
for _, segment := range segments {
|
||||||
|
base = filepath.Join(base, filepath.FromSlash(segment))
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWithinRoot(root, target string) bool {
|
||||||
|
rel, err := filepath.Rel(root, target)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtualName(name string) string {
|
||||||
|
return path.Base(slashClean(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Dir) resolveBoundedPath(name string, followLeaf, allowMissingLeaf, allowMissingTail bool) (string, error) {
|
||||||
|
if d.resolve(name) == "" {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
root, err := d.rootPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
current := root
|
||||||
|
segments := splitVirtualPath(slashClean(name))
|
||||||
|
symlinks := 0
|
||||||
|
|
||||||
|
for len(segments) > 0 {
|
||||||
|
segment := segments[0]
|
||||||
|
segments = segments[1:]
|
||||||
|
|
||||||
|
next := filepath.Join(current, filepath.FromSlash(segment))
|
||||||
|
final := len(segments) == 0
|
||||||
|
|
||||||
|
info, err := os.Lstat(next)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) && (allowMissingTail || (allowMissingLeaf && final)) {
|
||||||
|
return joinVirtualPath(current, append([]string{segment}, segments...)), nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
current = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !followLeaf && final {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
symlinks++
|
||||||
|
if symlinks > 255 {
|
||||||
|
return "", os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := os.Readlink(next)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(target) {
|
||||||
|
target = filepath.Join(current, target)
|
||||||
|
}
|
||||||
|
target = filepath.Clean(target)
|
||||||
|
|
||||||
|
if !isWithinRoot(root, target) {
|
||||||
|
return "", os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
relTarget, err := filepath.Rel(root, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", os.ErrPermission
|
||||||
|
}
|
||||||
|
targetSegments := splitVirtualPath(filepath.ToSlash(relTarget))
|
||||||
|
|
||||||
|
current = root
|
||||||
|
segments = append(targetSegments, segments...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d Dir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
func (d Dir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||||
if name = d.resolve(name); name == "" {
|
name, err := d.resolveBoundedPath(name, false, true, false)
|
||||||
return os.ErrNotExist
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return os.Mkdir(name, perm)
|
return os.Mkdir(name, perm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
|
func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
|
||||||
if name = d.resolve(name); name == "" {
|
resolvedName, err := d.resolveBoundedPath(name, true, flag&os.O_CREATE != 0, false)
|
||||||
return nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(name, flag, perm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &webdavFile{f, name}, nil
|
f, err := os.OpenFile(resolvedName, flag, perm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &webdavFile{
|
||||||
|
File: &osDirFile{
|
||||||
|
File: f,
|
||||||
|
statName: virtualName(name),
|
||||||
|
},
|
||||||
|
name: resolvedName,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Dir) RemoveAll(ctx context.Context, name string) error {
|
func (d Dir) RemoveAll(ctx context.Context, name string) error {
|
||||||
if name = d.resolve(name); name == "" {
|
resolvedName := d.resolve(name)
|
||||||
|
if resolvedName == "" {
|
||||||
return os.ErrNotExist
|
return os.ErrNotExist
|
||||||
}
|
}
|
||||||
if name == filepath.Clean(string(d)) {
|
if resolvedName == filepath.Clean(string(d)) {
|
||||||
// Prohibit removing the virtual root directory.
|
// Prohibit removing the virtual root directory.
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
return os.RemoveAll(name)
|
var err error
|
||||||
|
resolvedName, err = d.resolveBoundedPath(name, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(resolvedName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Dir) Rename(ctx context.Context, oldName, newName string) error {
|
func (d Dir) Rename(ctx context.Context, oldName, newName string) error {
|
||||||
if oldName = d.resolve(oldName); oldName == "" {
|
resolvedOldName := d.resolve(oldName)
|
||||||
|
if resolvedOldName == "" {
|
||||||
return os.ErrNotExist
|
return os.ErrNotExist
|
||||||
}
|
}
|
||||||
if newName = d.resolve(newName); newName == "" {
|
resolvedNewName := d.resolve(newName)
|
||||||
|
if resolvedNewName == "" {
|
||||||
return os.ErrNotExist
|
return os.ErrNotExist
|
||||||
}
|
}
|
||||||
if root := filepath.Clean(string(d)); root == oldName || root == newName {
|
if root := filepath.Clean(string(d)); root == resolvedOldName || root == resolvedNewName {
|
||||||
// Prohibit renaming from or to the virtual root directory.
|
// Prohibit renaming from or to the virtual root directory.
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
return os.Rename(oldName, newName)
|
var err error
|
||||||
|
resolvedOldName, err = d.resolveBoundedPath(oldName, false, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolvedNewName, err = d.resolveBoundedPath(newName, false, true, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(resolvedOldName, resolvedNewName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Dir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
func (d Dir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||||
if name = d.resolve(name); name == "" {
|
resolvedName, err := d.resolveBoundedPath(name, true, false, false)
|
||||||
return nil, os.ErrNotExist
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return os.Stat(name)
|
info, err := os.Stat(resolvedName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return namedFileInfo{FileInfo: info, name: virtualName(name)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemFS returns a new in-memory FileSystem implementation.
|
// NewMemFS returns a new in-memory FileSystem implementation.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package davServer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
@@ -510,6 +511,13 @@ func testFS(t *testing.T, fs FileSystem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func symlinkOrSkip(t *testing.T, target, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Symlink(target, name); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDir(t *testing.T) {
|
func TestDir(t *testing.T) {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "nacl":
|
case "nacl":
|
||||||
@@ -526,6 +534,168 @@ func TestDir(t *testing.T) {
|
|||||||
testFS(t, Dir(td))
|
testFS(t, Dir(td))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDirRejectsSymlinkEscape(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
|
secretName := filepath.Join(outside, "secret.txt")
|
||||||
|
if err := os.WriteFile(secretName, []byte("secret"), 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
symlinkOrSkip(t, secretName, filepath.Join(root, "escape.txt"))
|
||||||
|
|
||||||
|
fs := Dir(root)
|
||||||
|
|
||||||
|
if _, err := fs.Stat(ctx, "/escape.txt"); !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("Stat: got %v, want permission error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.OpenFile(ctx, "/escape.txt", os.O_RDONLY, 0); !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("OpenFile: got %v, want permission error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirRejectsSymlinkParentEscape(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("source"), 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
victimName := filepath.Join(outside, "victim.txt")
|
||||||
|
if err := os.WriteFile(victimName, []byte("victim"), 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile victim: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symlinkOrSkip(t, outside, filepath.Join(root, "escape"))
|
||||||
|
|
||||||
|
fs := Dir(root)
|
||||||
|
|
||||||
|
if _, err := fs.OpenFile(ctx, "/escape/created.txt", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600); !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("OpenFile create: got %v, want permission error", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outside, "created.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("outside create: got %v, want not exist", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.Mkdir(ctx, "/escape/subdir", 0755); !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("Mkdir: got %v, want permission error", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outside, "subdir")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("outside mkdir: got %v, want not exist", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.Rename(ctx, "/source.txt", "/escape/renamed.txt"); !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("Rename: got %v, want permission error", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outside, "renamed.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("outside rename: got %v, want not exist", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "source.txt")); err != nil {
|
||||||
|
t.Fatalf("source retained: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fs.RemoveAll(ctx, "/escape/victim.txt"); !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("RemoveAll: got %v, want permission error", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(victimName); err != nil {
|
||||||
|
t.Fatalf("victim retained: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirAllowsInternalSymlinks(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
targetName := filepath.Join(root, "target.txt")
|
||||||
|
if err := os.WriteFile(targetName, []byte("target"), 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symlinkOrSkip(t, "target.txt", filepath.Join(root, "link.txt"))
|
||||||
|
|
||||||
|
fs := Dir(root)
|
||||||
|
|
||||||
|
stat, err := fs.Stat(ctx, "/link.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
if stat.Name() != "link.txt" {
|
||||||
|
t.Fatalf("Stat.Name: got %q, want %q", stat.Name(), "link.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := fs.OpenFile(ctx, "/link.txt", os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file Stat: %v", err)
|
||||||
|
}
|
||||||
|
if info.Name() != "link.txt" {
|
||||||
|
t.Fatalf("file Stat.Name: got %q, want %q", info.Name(), "link.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(data); got != "target" {
|
||||||
|
t.Fatalf("ReadAll: got %q, want %q", got, "target")
|
||||||
|
}
|
||||||
|
|
||||||
|
actualDir := filepath.Join(root, "actual")
|
||||||
|
if err := os.Mkdir(actualDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Mkdir actual: %v", err)
|
||||||
|
}
|
||||||
|
symlinkOrSkip(t, "actual", filepath.Join(root, "nested"))
|
||||||
|
|
||||||
|
g, err := fs.OpenFile(ctx, "/nested/created.txt", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile nested: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := g.Write([]byte("created")); err != nil {
|
||||||
|
t.Fatalf("Write nested: %v", err)
|
||||||
|
}
|
||||||
|
if err := g.Close(); err != nil {
|
||||||
|
t.Fatalf("Close nested: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := os.ReadFile(filepath.Join(actualDir, "created.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile created: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(created); got != "created" {
|
||||||
|
t.Fatalf("ReadFile created: got %q, want %q", got, "created")
|
||||||
|
}
|
||||||
|
|
||||||
|
symlinkOrSkip(t, "created-via-link.txt", filepath.Join(root, "new-link.txt"))
|
||||||
|
|
||||||
|
h, err := fs.OpenFile(ctx, "/new-link.txt", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile broken link: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := h.Write([]byte("via-link")); err != nil {
|
||||||
|
t.Fatalf("Write broken link: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.Close(); err != nil {
|
||||||
|
t.Fatalf("Close broken link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
viaLink, err := os.ReadFile(filepath.Join(root, "created-via-link.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile broken link target: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(viaLink); got != "via-link" {
|
||||||
|
t.Fatalf("ReadFile broken link target: got %q, want %q", got, "via-link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMemFS(t *testing.T) {
|
func TestMemFS(t *testing.T) {
|
||||||
testFS(t, NewMemFS())
|
testFS(t, NewMemFS())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user