CVE-2026-42275
Description
zrok is software for sharing web services, files, and network resources. Prior to version 2.0.2, the zrok WebDAV drive backend (davServer.Dir) restricts path traversal through lexical normalization but does not prevent symlink following. When a symbolic link inside the shared DriveRoot points to a location outside that root, remote WebDAV consumers can read files and—on shares without OS-level permission restrictions—write or overwrite files anywhere on the host filesystem accessible to the zrok process. This issue has been patched in version 2.0.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/openziti/zrokGo | <= 1.1.11 | — |
github.com/openziti/zrok/v2Go | < 2.0.2 | 2.0.2 |
Affected products
2Patches
1459bcfc1e121Merge pull request #1228 from openziti/v2.0.2_drive_symlinks
4 files changed · +355 −17
CHANGELOG.md+4 −0 modified@@ -1,5 +1,9 @@ # 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 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)
drives/davServer/file.go+179 −16 modified@@ -57,6 +57,28 @@ type File interface { 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 { File name string @@ -130,54 +152,195 @@ func (d Dir) resolve(name string) string { 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 { - if name = d.resolve(name); name == "" { - return os.ErrNotExist + name, err := d.resolveBoundedPath(name, false, true, false) + if err != nil { + return err } return os.Mkdir(name, perm) } func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) { - if name = d.resolve(name); name == "" { - return nil, os.ErrNotExist + resolvedName, err := d.resolveBoundedPath(name, true, flag&os.O_CREATE != 0, false) + if err != nil { + return nil, err } - f, err := os.OpenFile(name, flag, perm) + f, err := os.OpenFile(resolvedName, flag, perm) if err != nil { return nil, err } - return &webdavFile{f, name}, nil + return &webdavFile{ + File: &osDirFile{ + File: f, + statName: virtualName(name), + }, + name: resolvedName, + }, nil } 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 } - if name == filepath.Clean(string(d)) { + if resolvedName == filepath.Clean(string(d)) { // Prohibit removing the virtual root directory. 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 { - if oldName = d.resolve(oldName); oldName == "" { + resolvedOldName := d.resolve(oldName) + if resolvedOldName == "" { return os.ErrNotExist } - if newName = d.resolve(newName); newName == "" { + resolvedNewName := d.resolve(newName) + if resolvedNewName == "" { 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. 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) { - if name = d.resolve(name); name == "" { - return nil, os.ErrNotExist + resolvedName, err := d.resolveBoundedPath(name, true, false, false) + if err != nil { + return nil, err + } + info, err := os.Stat(resolvedName) + if err != nil { + return nil, err } - return os.Stat(name) + return namedFileInfo{FileInfo: info, name: virtualName(name)}, nil } // NewMemFS returns a new in-memory FileSystem implementation.
drives/davServer/file_test.go+170 −0 modified@@ -7,6 +7,7 @@ package davServer import ( "context" "encoding/xml" + "errors" "fmt" "io" "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) { switch runtime.GOOS { case "nacl": @@ -526,6 +534,168 @@ func TestDir(t *testing.T) { 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) { testFS(t, NewMemFS()) }
.gitignore+2 −1 modified@@ -4,7 +4,8 @@ .vscode .contexts CLAUDE.md -/.claude/ +.claude +.codex AGENTS.md *.db /automated-release-build/
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/openziti/zrok/commit/459bcfc1e121decae1b1d11c37ad94e4ed5bbf2envdPatchWEB
- github.com/advisories/GHSA-74m3-9qvm-rp9hghsaADVISORY
- github.com/openziti/zrok/security/advisories/GHSA-74m3-9qvm-rp9hnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-42275ghsaADVISORY
- github.com/openziti/zrok/releases/tag/v2.0.2nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.