VYPR
High severityNVD Advisory· Published Feb 4, 2026· Updated Feb 5, 2026

Alist vulnerable to Path Traversal in multiple file operation handlers

CVE-2026-25161

Description

Alist is a file list program that supports multiple storages, powered by Gin and Solidjs. Prior to version 3.57.0, the application contains path traversal vulnerability in multiple file operation handlers. An authenticated attacker can bypass directory-level authorisation by injecting traversal sequences into filename components, enabling unauthorised file removal, movement and copying across user boundaries within the same storage mount. This issue has been patched in version 3.57.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Alist before 3.57.0 has a path traversal vulnerability in file operations, allowing authenticated attackers to delete, move, or copy files across user directories within the same storage mount.

Vulnerability

Overview

Alist, a file list program prior to version 3.57.0 contains a path traversal vulnerability (CWE-22) in multiple file operation handlers, including FsRemove, FsCopy, and FsBatchRename [1][2]. The root cause is that filename components from user requests (e.g., req.Names, renameObject.SrcName) are directly concatenated with validated directories using stdpath.Join() or fmt.Sprintf() without sanitizing ..` sequences [2]. This allows an authenticated attacker to bypass directory-level authorization and access files outside their designated base path within the same storage mount.

Exploitation

An attacker must be authenticated to the Alist instance and have access to a storage mount shared with other users [1][2]. The attack is performed by sending crafted API requests (e.g., to /api/fs/remove) with filenames containing ../ traversal sequences [2]. For example, a user with base path /shared/alice can send a request with names: ["../admin/private.txt"] to delete files in the admin's directory [2]. No special network position is required beyond normal API access.

Impact

Successful exploitation enables an attacker to perform unauthorized file operations—deletion, renaming, moving, or copying—on files belonging to other users within the same storage mount [1][2]. This can lead to data loss, unauthorized data access, or disruption of service. The vulnerability does not require elevated privileges beyond standard user authentication.

Mitigation

The issue has been patched in Alist version 3.57.0 [1][2]. Users should upgrade to this version or later. The fix likely involves proper path sanitization or validation of user-supplied filenames before concatenation [4]. No workarounds are mentioned in the available references.

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/alist-org/alist/v3Go
< 3.57.03.57.0

Affected products

2
  • Alist/Alistllm-create
    Range: <3.57.0
  • AlistGo/alistv5
    Range: < 3.57.0

Patches

1
b188288525b9

Merge commit from fork

https://github.com/AlistGo/alist千石Feb 3, 2026via ghsa
7 files changed · +310 68
  • internal/archive/archives/archives.go+38 8 modified
    @@ -1,10 +1,12 @@
     package archives
     
     import (
    +	"fmt"
     	"io"
     	"io/fs"
     	"os"
     	stdpath "path"
    +	"path/filepath"
     	"strings"
     
     	"github.com/alist-org/alist/v3/internal/archive/tool"
    @@ -106,7 +108,7 @@ func (Archives) Decompress(ss []*stream.SeekableStream, outputPath string, args
     		}
     		if stat.IsDir() {
     			isDir = true
    -			outputPath = stdpath.Join(outputPath, stat.Name())
    +			outputPath = filepath.Join(outputPath, stat.Name())
     			err = os.Mkdir(outputPath, 0700)
     			if err != nil {
     				return filterPassword(err)
    @@ -118,18 +120,46 @@ func (Archives) Decompress(ss []*stream.SeekableStream, outputPath string, args
     			if err != nil {
     				return err
     			}
    +			if p == path {
    +				if d.IsDir() {
    +					return nil
    +				}
    +			}
     			relPath := strings.TrimPrefix(p, path+"/")
    -			dstPath := stdpath.Join(outputPath, relPath)
    +			if relPath == "" || relPath == "." {
    +				if d.IsDir() {
    +					return nil
    +				}
    +			}
    +			dstPath, err := tool.SecureJoin(outputPath, relPath)
    +			if err != nil {
    +				return err
    +			}
     			if d.IsDir() {
    -				err = os.MkdirAll(dstPath, 0700)
    -			} else {
    -				dir := stdpath.Dir(dstPath)
    -				err = decompress(fsys, p, dir, func(_ float64) {})
    +				return os.MkdirAll(dstPath, 0700)
     			}
    -			return err
    +			info, err := d.Info()
    +			if err != nil {
    +				return err
    +			}
    +			if !info.Mode().IsRegular() {
    +				return fmt.Errorf("%w: %s", tool.ErrArchiveIllegalPath, p)
    +			}
    +			if err := os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +				return err
    +			}
    +			return decompress(fsys, p, dstPath, func(_ float64) {})
     		})
     	} else {
    -		err = decompress(fsys, path, outputPath, up)
    +		entryName := stdpath.Base(path)
    +		dstPath, e := tool.SecureJoin(outputPath, entryName)
    +		if e != nil {
    +			return e
    +		}
    +		if err = os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +			return err
    +		}
    +		err = decompress(fsys, path, dstPath, up)
     	}
     	return filterPassword(err)
     }
    
  • internal/archive/archives/utils.go+7 3 modified
    @@ -1,12 +1,13 @@
     package archives
     
     import (
    +	"fmt"
     	"io"
     	fs2 "io/fs"
     	"os"
    -	stdpath "path"
     	"strings"
     
    +	"github.com/alist-org/alist/v3/internal/archive/tool"
     	"github.com/alist-org/alist/v3/internal/errs"
     	"github.com/alist-org/alist/v3/internal/model"
     	"github.com/alist-org/alist/v3/internal/stream"
    @@ -59,7 +60,7 @@ func filterPassword(err error) error {
     	return err
     }
     
    -func decompress(fsys fs2.FS, filePath, targetPath string, up model.UpdateProgress) error {
    +func decompress(fsys fs2.FS, filePath, dstPath string, up model.UpdateProgress) error {
     	rc, err := fsys.Open(filePath)
     	if err != nil {
     		return err
    @@ -69,7 +70,10 @@ func decompress(fsys fs2.FS, filePath, targetPath string, up model.UpdateProgres
     	if err != nil {
     		return err
     	}
    -	f, err := os.OpenFile(stdpath.Join(targetPath, stat.Name()), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
    +	if !stat.Mode().IsRegular() {
    +		return fmt.Errorf("%w: %s", tool.ErrArchiveIllegalPath, filePath)
    +	}
    +	f, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
     	if err != nil {
     		return err
     	}
    
  • internal/archive/rardecode/rardecode.go+50 9 modified
    @@ -1,15 +1,18 @@
     package rardecode
     
     import (
    +	"fmt"
    +	"io"
    +	"os"
    +	stdpath "path"
    +	"path/filepath"
    +	"strings"
    +
     	"github.com/alist-org/alist/v3/internal/archive/tool"
     	"github.com/alist-org/alist/v3/internal/errs"
     	"github.com/alist-org/alist/v3/internal/model"
     	"github.com/alist-org/alist/v3/internal/stream"
     	"github.com/nwaples/rardecode/v2"
    -	"io"
    -	"os"
    -	stdpath "path"
    -	"strings"
     )
     
     type RarDecoder struct{}
    @@ -85,7 +88,11 @@ func (RarDecoder) Decompress(ss []*stream.SeekableStream, outputPath string, arg
     			if header.IsDir {
     				name = name + "/"
     			}
    -			err = decompress(reader, header, name, outputPath)
    +			dstPath, e := tool.SecureJoin(outputPath, name)
    +			if e != nil {
    +				return e
    +			}
    +			err = decompress(reader, header, dstPath)
     			if err != nil {
     				return err
     			}
    @@ -94,6 +101,7 @@ func (RarDecoder) Decompress(ss []*stream.SeekableStream, outputPath string, arg
     		innerPath := strings.TrimPrefix(args.InnerPath, "/")
     		innerBase := stdpath.Base(innerPath)
     		createdBaseDir := false
    +		var baseDirPath string
     		for {
     			var header *rardecode.FileHeader
     			header, err = reader.Next()
    @@ -108,22 +116,55 @@ func (RarDecoder) Decompress(ss []*stream.SeekableStream, outputPath string, arg
     				name = name + "/"
     			}
     			if name == innerPath {
    -				err = _decompress(reader, header, outputPath, up)
    +				if header.IsDir {
    +					if !createdBaseDir {
    +						baseDirPath, err = tool.SecureJoin(outputPath, innerBase)
    +						if err != nil {
    +							return err
    +						}
    +						if err = os.MkdirAll(baseDirPath, 0700); err != nil {
    +							return err
    +						}
    +						createdBaseDir = true
    +					}
    +					continue
    +				}
    +				if !header.Mode().IsRegular() {
    +					return fmt.Errorf("%w: %s", tool.ErrArchiveIllegalPath, header.Name)
    +				}
    +				dstPath, e := tool.SecureJoin(outputPath, stdpath.Base(innerPath))
    +				if e != nil {
    +					return e
    +				}
    +				if err = os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +					return err
    +				}
    +				err = _decompress(reader, header, dstPath, up)
     				if err != nil {
     					return err
     				}
     				break
     			} else if strings.HasPrefix(name, innerPath+"/") {
    -				targetPath := stdpath.Join(outputPath, innerBase)
     				if !createdBaseDir {
    -					err = os.Mkdir(targetPath, 0700)
    +					baseDirPath, err = tool.SecureJoin(outputPath, innerBase)
    +					if err != nil {
    +						return err
    +					}
    +					err = os.MkdirAll(baseDirPath, 0700)
     					if err != nil {
     						return err
     					}
     					createdBaseDir = true
     				}
     				restPath := strings.TrimPrefix(name, innerPath+"/")
    -				err = decompress(reader, header, restPath, targetPath)
    +				if restPath == "" || restPath == "." {
    +					continue
    +				}
    +				dstPath, e := tool.SecureJoin(baseDirPath, restPath)
    +				if e != nil {
    +					return e
    +				}
    +				err = decompress(reader, header, dstPath)
     				if err != nil {
     					return err
     				}
    
  • internal/archive/rardecode/utils.go+18 22 modified
    @@ -2,18 +2,20 @@ package rardecode
     
     import (
     	"fmt"
    -	"github.com/alist-org/alist/v3/internal/archive/tool"
    -	"github.com/alist-org/alist/v3/internal/errs"
    -	"github.com/alist-org/alist/v3/internal/model"
    -	"github.com/alist-org/alist/v3/internal/stream"
    -	"github.com/nwaples/rardecode/v2"
     	"io"
     	"io/fs"
     	"os"
     	stdpath "path"
    +	"path/filepath"
     	"sort"
     	"strings"
     	"time"
    +
    +	"github.com/alist-org/alist/v3/internal/archive/tool"
    +	"github.com/alist-org/alist/v3/internal/errs"
    +	"github.com/alist-org/alist/v3/internal/model"
    +	"github.com/alist-org/alist/v3/internal/stream"
    +	"github.com/nwaples/rardecode/v2"
     )
     
     type VolumeFile struct {
    @@ -179,27 +181,21 @@ func getReader(ss []*stream.SeekableStream, password string) (*rardecode.Reader,
     	return &rc.Reader, nil
     }
     
    -func decompress(reader *rardecode.Reader, header *rardecode.FileHeader, filePath, outputPath string) error {
    -	targetPath := outputPath
    -	dir, base := stdpath.Split(filePath)
    -	if dir != "" {
    -		targetPath = stdpath.Join(targetPath, dir)
    -		err := os.MkdirAll(targetPath, 0700)
    -		if err != nil {
    -			return err
    -		}
    +func decompress(reader *rardecode.Reader, header *rardecode.FileHeader, dstPath string) error {
    +	if header.IsDir {
    +		return os.MkdirAll(dstPath, 0700)
     	}
    -	if base != "" {
    -		err := _decompress(reader, header, targetPath, func(_ float64) {})
    -		if err != nil {
    -			return err
    -		}
    +	if !header.Mode().IsRegular() {
    +		return fmt.Errorf("%w: %s", tool.ErrArchiveIllegalPath, header.Name)
     	}
    -	return nil
    +	if err := os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +		return err
    +	}
    +	return _decompress(reader, header, dstPath, func(_ float64) {})
     }
     
    -func _decompress(reader *rardecode.Reader, header *rardecode.FileHeader, targetPath string, up model.UpdateProgress) error {
    -	f, err := os.OpenFile(stdpath.Join(targetPath, stdpath.Base(header.Name)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
    +func _decompress(reader *rardecode.Reader, header *rardecode.FileHeader, dstPath string, up model.UpdateProgress) error {
    +	f, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
     	if err != nil {
     		return err
     	}
    
  • internal/archive/tool/helper.go+87 26 modified
    @@ -1,10 +1,12 @@
     package tool
     
     import (
    +	"fmt"
     	"io"
     	"io/fs"
     	"os"
     	stdpath "path"
    +	"path/filepath"
     	"strings"
     
     	"github.com/alist-org/alist/v3/internal/model"
    @@ -119,7 +121,30 @@ func DecompressFromFolderTraversal(r ArchiveReader, outputPath string, args mode
     	if args.InnerPath == "/" {
     		for i, file := range files {
     			name := file.Name()
    -			err = decompress(file, name, outputPath, args.Password)
    +			info := file.FileInfo()
    +			if info.IsDir() {
    +				var dirPath string
    +				dirPath, err = SecureJoin(outputPath, name)
    +				if err != nil {
    +					return err
    +				}
    +				if err = os.MkdirAll(dirPath, 0700); err != nil {
    +					return err
    +				}
    +				continue
    +			}
    +			if !info.Mode().IsRegular() {
    +				return fmt.Errorf("%w: %s", ErrArchiveIllegalPath, name)
    +			}
    +			var dstPath string
    +			dstPath, err = SecureJoin(outputPath, name)
    +			if err != nil {
    +				return err
    +			}
    +			if err = os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +				return err
    +			}
    +			err = _decompress(file, dstPath, args.Password, func(_ float64) {})
     			if err != nil {
     				return err
     			}
    @@ -129,25 +154,80 @@ func DecompressFromFolderTraversal(r ArchiveReader, outputPath string, args mode
     		innerPath := strings.TrimPrefix(args.InnerPath, "/")
     		innerBase := stdpath.Base(innerPath)
     		createdBaseDir := false
    +		var baseDirPath string
     		for _, file := range files {
     			name := file.Name()
     			if name == innerPath {
    -				err = _decompress(file, outputPath, args.Password, up)
    +				info := file.FileInfo()
    +				if info.IsDir() {
    +					if !createdBaseDir {
    +						baseDirPath, err = SecureJoin(outputPath, innerBase)
    +						if err != nil {
    +							return err
    +						}
    +						if err = os.MkdirAll(baseDirPath, 0700); err != nil {
    +							return err
    +						}
    +						createdBaseDir = true
    +					}
    +					continue
    +				}
    +				if !info.Mode().IsRegular() {
    +					return fmt.Errorf("%w: %s", ErrArchiveIllegalPath, name)
    +				}
    +				var dstPath string
    +				dstPath, err = SecureJoin(outputPath, stdpath.Base(innerPath))
    +				if err != nil {
    +					return err
    +				}
    +				if err = os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +					return err
    +				}
    +				err = _decompress(file, dstPath, args.Password, up)
     				if err != nil {
     					return err
     				}
     				break
     			} else if strings.HasPrefix(name, innerPath+"/") {
    -				targetPath := stdpath.Join(outputPath, innerBase)
     				if !createdBaseDir {
    -					err = os.Mkdir(targetPath, 0700)
    +					baseDirPath, err = SecureJoin(outputPath, innerBase)
    +					if err != nil {
    +						return err
    +					}
    +					err = os.MkdirAll(baseDirPath, 0700)
     					if err != nil {
     						return err
     					}
     					createdBaseDir = true
     				}
     				restPath := strings.TrimPrefix(name, innerPath+"/")
    -				err = decompress(file, restPath, targetPath, args.Password)
    +				if restPath == "" || restPath == "." {
    +					continue
    +				}
    +				info := file.FileInfo()
    +				if info.IsDir() {
    +					var dirPath string
    +					dirPath, err = SecureJoin(baseDirPath, restPath)
    +					if err != nil {
    +						return err
    +					}
    +					if err = os.MkdirAll(dirPath, 0700); err != nil {
    +						return err
    +					}
    +					continue
    +				}
    +				if !info.Mode().IsRegular() {
    +					return fmt.Errorf("%w: %s", ErrArchiveIllegalPath, name)
    +				}
    +				var dstPath string
    +				dstPath, err = SecureJoin(baseDirPath, restPath)
    +				if err != nil {
    +					return err
    +				}
    +				if err = os.MkdirAll(filepath.Dir(dstPath), 0700); err != nil {
    +					return err
    +				}
    +				err = _decompress(file, dstPath, args.Password, func(_ float64) {})
     				if err != nil {
     					return err
     				}
    @@ -157,26 +237,7 @@ func DecompressFromFolderTraversal(r ArchiveReader, outputPath string, args mode
     	return nil
     }
     
    -func decompress(file SubFile, filePath, outputPath, password string) error {
    -	targetPath := outputPath
    -	dir, base := stdpath.Split(filePath)
    -	if dir != "" {
    -		targetPath = stdpath.Join(targetPath, dir)
    -		err := os.MkdirAll(targetPath, 0700)
    -		if err != nil {
    -			return err
    -		}
    -	}
    -	if base != "" {
    -		err := _decompress(file, targetPath, password, func(_ float64) {})
    -		if err != nil {
    -			return err
    -		}
    -	}
    -	return nil
    -}
    -
    -func _decompress(file SubFile, targetPath, password string, up model.UpdateProgress) error {
    +func _decompress(file SubFile, dstPath, password string, up model.UpdateProgress) error {
     	if encrypt, ok := file.(CanEncryptSubFile); ok && encrypt.IsEncrypted() {
     		encrypt.SetPassword(password)
     	}
    @@ -185,7 +246,7 @@ func _decompress(file SubFile, targetPath, password string, up model.UpdateProgr
     		return err
     	}
     	defer func() { _ = rc.Close() }()
    -	f, err := os.OpenFile(stdpath.Join(targetPath, file.FileInfo().Name()), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
    +	f, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
     	if err != nil {
     		return err
     	}
    
  • internal/archive/tool/securepath.go+62 0 added
    @@ -0,0 +1,62 @@
    +package tool
    +
    +import (
    +	"errors"
    +	"fmt"
    +	"path"
    +	"path/filepath"
    +	"strings"
    +)
    +
    +// ErrArchiveIllegalPath indicates an archive entry path is unsafe for extraction.
    +var ErrArchiveIllegalPath = errors.New("archive entry has illegal path")
    +
    +// SecureJoin returns a safe extraction path for an archive entry.
    +// It rejects absolute paths, traversal, Windows drive/UNC paths, and NUL bytes.
    +func SecureJoin(baseDir, entryName string) (string, error) {
    +	if strings.Contains(entryName, "\x00") {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +
    +	normalized := strings.ReplaceAll(entryName, "\\", "/")
    +	if strings.HasPrefix(normalized, "//") {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +	cleaned := path.Clean(normalized)
    +
    +	if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +	if strings.HasPrefix(cleaned, "/") {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +
    +	rel := filepath.FromSlash(cleaned)
    +	if filepath.IsAbs(rel) || filepath.VolumeName(rel) != "" {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +	if strings.HasPrefix(rel, `\\`) {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +
    +	base := filepath.Clean(baseDir)
    +	dst := filepath.Join(base, rel)
    +
    +	baseAbs, err := filepath.Abs(base)
    +	if err != nil {
    +		return "", fmt.Errorf("%w: %s (%v)", ErrArchiveIllegalPath, entryName, err)
    +	}
    +	dstAbs, err := filepath.Abs(dst)
    +	if err != nil {
    +		return "", fmt.Errorf("%w: %s (%v)", ErrArchiveIllegalPath, entryName, err)
    +	}
    +
    +	relCheck, err := filepath.Rel(baseAbs, dstAbs)
    +	if err != nil {
    +		return "", fmt.Errorf("%w: %s (%v)", ErrArchiveIllegalPath, entryName, err)
    +	}
    +	if relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(os.PathSeparator)) {
    +		return "", fmt.Errorf("%w: %s", ErrArchiveIllegalPath, entryName)
    +	}
    +	return dst, nil
    +}
    
  • internal/archive/tool/securepath_test.go+48 0 added
    @@ -0,0 +1,48 @@
    +package tool
    +
    +import (
    +	"path/filepath"
    +	"strings"
    +	"testing"
    +)
    +
    +func TestSecureJoin(t *testing.T) {
    +	baseDir := t.TempDir()
    +	tests := []struct {
    +		name    string
    +		entry   string
    +		wantErr bool
    +	}{
    +		{name: "ok", entry: "a/b/c.txt", wantErr: false},
    +		{name: "parent", entry: "../evil.txt", wantErr: true},
    +		{name: "parent-backslash", entry: "..\\evil.txt", wantErr: true},
    +		{name: "abs", entry: "/tmp/evil.txt", wantErr: true},
    +		{name: "drive", entry: "C:\\evil.txt", wantErr: true},
    +		{name: "unc", entry: "\\\\server\\share\\evil.txt", wantErr: true},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			dst, err := SecureJoin(baseDir, tc.entry)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error for %q, got nil", tc.entry)
    +				}
    +				if !strings.Contains(err.Error(), tc.entry) {
    +					t.Fatalf("error should include entry name %q, got %q", tc.entry, err.Error())
    +				}
    +				return
    +			}
    +			if err != nil {
    +				t.Fatalf("unexpected error for %q: %v", tc.entry, err)
    +			}
    +			rel, err := filepath.Rel(baseDir, dst)
    +			if err != nil {
    +				t.Fatalf("Rel failed: %v", err)
    +			}
    +			if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
    +				t.Fatalf("path escaped baseDir: %q", dst)
    +			}
    +		})
    +	}
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.