Copilot commented on code in PR #1135:
URL: 
https://github.com/apache/skywalking-banyandb/pull/1135#discussion_r3265242869


##########
pkg/fs/remote/local/local.go:
##########
@@ -89,10 +108,75 @@ func (l *fs) List(_ context.Context, prefix string) 
([]string, error) {
 }
 
 func (l *fs) Delete(_ context.Context, path string) error {
-       fullPath := filepath.Join(l.baseDir, path)
+       fullPath, err := l.fullPath(path)
+       if err != nil {
+               return err
+       }
        return os.Remove(fullPath)
 }
 
 func (l *fs) Close() error {
        return nil
 }
+
+func (l *fs) fullPath(path string) (string, error) {
+       if filepath.IsAbs(path) || hasVolumeName(path) {
+               return "", fmt.Errorf("path %q escapes base directory", path)
+       }
+       cleanPath := filepath.Clean(path)
+       if hasVolumeName(cleanPath) || cleanPath == ".." || 
strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {

Review Comment:
   `fullPath` currently allows `path == ""` and `path == "."` (since 
`filepath.Clean("")` becomes `"."`). That means `Delete(".", ...)` targets 
`l.baseDir` itself, and `Download(".")` opens the base directory; depending on 
runtime conditions (e.g., empty directory), this broadens what a caller can 
affect beyond a “file under baseDir”. Consider rejecting `"."`/empty explicitly 
(or at least disallowing them for `Delete`), returning the same “escapes base 
directory” error to keep behavior consistent.
   



##########
pkg/fs/remote/local/local.go:
##########
@@ -89,10 +108,75 @@ func (l *fs) List(_ context.Context, prefix string) 
([]string, error) {
 }
 
 func (l *fs) Delete(_ context.Context, path string) error {
-       fullPath := filepath.Join(l.baseDir, path)
+       fullPath, err := l.fullPath(path)
+       if err != nil {
+               return err
+       }
        return os.Remove(fullPath)
 }
 
 func (l *fs) Close() error {
        return nil
 }
+
+func (l *fs) fullPath(path string) (string, error) {
+       if filepath.IsAbs(path) || hasVolumeName(path) {
+               return "", fmt.Errorf("path %q escapes base directory", path)
+       }
+       cleanPath := filepath.Clean(path)
+       if hasVolumeName(cleanPath) || cleanPath == ".." || 
strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
+               return "", fmt.Errorf("path %q escapes base directory", path)
+       }
+       fullPath := filepath.Join(l.baseDir, cleanPath)
+       relPath, err := filepath.Rel(l.baseDir, fullPath)
+       if err != nil {
+               return "", err
+       }
+       if relPath == ".." || strings.HasPrefix(relPath, 
".."+string(filepath.Separator)) || filepath.IsAbs(relPath) {
+               return "", fmt.Errorf("path %q escapes base directory", path)
+       }
+       if err := l.ensureResolvedWithinBase(fullPath); err != nil {
+               return "", fmt.Errorf("path %q escapes base directory: %w", 
path, err)
+       }
+       return fullPath, nil
+}
+
+func (l *fs) ensureResolvedWithinBase(path string) error {
+       existingPath := path
+       for {
+               if _, err := os.Lstat(existingPath); err == nil {
+                       break
+               } else if !os.IsNotExist(err) {
+                       return err
+               }
+               parentPath := filepath.Dir(existingPath)
+               if parentPath == existingPath {
+                       return os.ErrNotExist
+               }
+               existingPath = parentPath
+       }
+
+       realPath, err := filepath.EvalSymlinks(existingPath)
+       if err != nil {
+               return err
+       }

Review Comment:
   There is a check-then-use window between 
`fullPath`/`ensureResolvedWithinBase` and the subsequent filesystem operations 
(`MkdirAll`, `Create`, `Open`, `Remove`). An attacker with write access under 
`baseDir` could potentially flip a directory component into a symlink after 
validation but before the operation (TOCTOU), reintroducing a symlink-escape 
vector. If this code is used in an untrusted environment, consider tightening 
this by validating the resolved parent directory immediately before the final 
open/create, or by using OS-specific “no-follow” primitives where feasible 
(noting portability tradeoffs).



##########
banyand/backup/restore.go:
##########
@@ -244,6 +243,38 @@ func restoreByName(fs remote.FS, timeDir, rootPath, 
catalogName string) error {
        return nil
 }
 
+func validatedRemoteRelPath(timeDir, catalogName, remoteFile string) (string, 
error) {
+       remotePath := filepath.ToSlash(remoteFile)
+       if path.IsAbs(remotePath) || hasRemoteVolumeName(remotePath) {
+               return "", fmt.Errorf("remote file %q escapes backup prefix", 
remoteFile)
+       }
+       prefix := path.Clean(filepath.ToSlash(filepath.Join(timeDir, 
catalogName)))
+       cleanRemotePath := path.Clean(remotePath)
+       if hasRemoteVolumeName(cleanRemotePath) || cleanRemotePath == "." || 
prefix == "." {
+               return "", fmt.Errorf("remote file %q escapes backup prefix", 
remoteFile)
+       }
+       relPath, err := filepath.Rel(filepath.FromSlash(prefix), 
filepath.FromSlash(cleanRemotePath))
+       if err != nil {
+               return "", fmt.Errorf("failed to get relative path for %s: %w", 
remoteFile, err)
+       }
+       relPath = filepath.ToSlash(relPath)
+       if relPath == "." || strings.HasPrefix(relPath, "../") || relPath == 
".." || path.IsAbs(relPath) {
+               return "", fmt.Errorf("remote file %q escapes backup prefix", 
remoteFile)
+       }
+       return relPath, nil
+}

Review Comment:
   `validatedRemoteRelPath` mixes `path` (POSIX `/`) and `filepath` 
(OS-specific) and requires multiple `ToSlash`/`FromSlash` conversions, which 
makes correctness harder to reason about (especially on Windows). Since 
`remoteFile` values are treated as “remote paths” (slash-separated), consider 
using `path.Clean` + `path.Rel` end-to-end (plus your volume-name guard) and 
keeping `filepath` only for local filesystem paths. This reduces 
platform-dependent behavior and simplifies the invariant: “remote paths always 
use `/`”.



##########
pkg/fs/remote/local/local.go:
##########
@@ -60,15 +73,21 @@ func (l *fs) Upload(_ context.Context, path string, data 
io.Reader) error {
 }
 
 func (l *fs) Download(_ context.Context, path string) (io.ReadCloser, error) {
-       fullPath := filepath.Join(l.baseDir, path)
+       fullPath, err := l.fullPath(path)
+       if err != nil {
+               return nil, err
+       }
        return os.Open(fullPath)
 }
 
 func (l *fs) List(_ context.Context, prefix string) ([]string, error) {
        var files []string
-       fullPath := filepath.Join(l.baseDir, prefix)
+       fullPath, err := l.fullPath(prefix)
+       if err != nil {
+               return nil, err
+       }
 
-       err := filepath.Walk(fullPath, func(path string, info os.FileInfo, err 
error) error {
+       err = filepath.Walk(fullPath, func(path string, info os.FileInfo, err 
error) error {
                if err != nil {
                        return err

Review Comment:
   `List` will return an error when `prefix` doesn’t exist because 
`filepath.Walk(fullPath, ...)` fails with `os.ErrNotExist`. This directly 
contradicts the new `TestFSListMissingPrefixReturnsEmpty` test, which expects 
`(empty slice, nil)`. Handle the missing-root case explicitly (e.g., detect 
`os.IsNotExist(err)` from `Walk`/`Stat` and return an empty list without error).



##########
banyand/backup/restore.go:
##########
@@ -244,6 +243,38 @@ func restoreByName(fs remote.FS, timeDir, rootPath, 
catalogName string) error {
        return nil
 }
 
+func validatedRemoteRelPath(timeDir, catalogName, remoteFile string) (string, 
error) {
+       remotePath := filepath.ToSlash(remoteFile)
+       if path.IsAbs(remotePath) || hasRemoteVolumeName(remotePath) {
+               return "", fmt.Errorf("remote file %q escapes backup prefix", 
remoteFile)
+       }
+       prefix := path.Clean(filepath.ToSlash(filepath.Join(timeDir, 
catalogName)))
+       cleanRemotePath := path.Clean(remotePath)
+       if hasRemoteVolumeName(cleanRemotePath) || cleanRemotePath == "." || 
prefix == "." {
+               return "", fmt.Errorf("remote file %q escapes backup prefix", 
remoteFile)
+       }
+       relPath, err := filepath.Rel(filepath.FromSlash(prefix), 
filepath.FromSlash(cleanRemotePath))
+       if err != nil {
+               return "", fmt.Errorf("failed to get relative path for %s: %w", 
remoteFile, err)
+       }
+       relPath = filepath.ToSlash(relPath)
+       if relPath == "." || strings.HasPrefix(relPath, "../") || relPath == 
".." || path.IsAbs(relPath) {
+               return "", fmt.Errorf("remote file %q escapes backup prefix", 
remoteFile)
+       }
+       return relPath, nil
+}
+
+func hasRemoteVolumeName(path string) bool {
+       if filepath.VolumeName(filepath.FromSlash(path)) != "" {
+               return true
+       }
+       return len(path) >= 2 && isASCIILetter(path[0]) && path[1] == ':'
+}
+
+func isASCIILetter(c byte) bool {
+       return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+}

Review Comment:
   `hasRemoteVolumeName`/`isASCIILetter` duplicate the equivalent helpers 
introduced in `pkg/fs/remote/local/local.go`. To prevent the two 
implementations drifting over time, consider extracting a shared helper (e.g., 
a small internal package) or reusing a single implementation if the repository 
layout allows it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to