fix(backend): harden write registrar boundaries
This commit is contained in:
@@ -14,8 +14,17 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
root string
|
||||
db *gorm.DB
|
||||
root string
|
||||
db *gorm.DB
|
||||
createTemp func(string, string) (stagedFile, error)
|
||||
rename func(string, string) error
|
||||
remove func(string) error
|
||||
}
|
||||
|
||||
type stagedFile interface {
|
||||
io.Writer
|
||||
Close() error
|
||||
Name() string
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
@@ -30,7 +39,12 @@ func NewService(root string, databases ...*gorm.DB) *Service {
|
||||
if len(databases) > 0 {
|
||||
database = databases[0]
|
||||
}
|
||||
return &Service{root: root, db: database}
|
||||
return &Service{
|
||||
root: root, db: database,
|
||||
createTemp: func(directory, pattern string) (stagedFile, error) { return os.CreateTemp(directory, pattern) },
|
||||
rename: os.Rename,
|
||||
remove: os.Remove,
|
||||
}
|
||||
}
|
||||
|
||||
// Save 清理客户端文件名,并只返回供持久化的相对路径;绝对路径不得进入 API DTO。
|
||||
@@ -40,21 +54,90 @@ func (s *Service) Save(projectID uint, originalName string, content io.Reader) (
|
||||
cleanName = "upload.bin"
|
||||
}
|
||||
relative := filepath.ToSlash(filepath.Join("projects", fmt.Sprint(projectID), fmt.Sprintf("%d-%s", time.Now().UnixNano(), cleanName)))
|
||||
absolute := filepath.Join(s.root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
file, err := os.Create(absolute)
|
||||
absolute, err := s.absolutePath(relative)
|
||||
if err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
// 临时文件与最终文件位于同一目录,Close 成功后再原子替换,避免暴露半写入内容。
|
||||
file, err := s.createTemp(filepath.Dir(absolute), ".upload-*")
|
||||
if err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, content); err != nil {
|
||||
_ = file.Close()
|
||||
s.cleanupFailedSave(file.Name(), absolute)
|
||||
return StoredFile{}, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
s.cleanupFailedSave(file.Name(), absolute)
|
||||
return StoredFile{}, err
|
||||
}
|
||||
if err := s.rename(file.Name(), absolute); err != nil {
|
||||
s.cleanupFailedSave(file.Name(), absolute)
|
||||
return StoredFile{}, err
|
||||
}
|
||||
return StoredFile{OriginalName: cleanName, RelativePath: relative, AbsolutePath: absolute}, nil
|
||||
}
|
||||
|
||||
// Remove 只依据 Service 生成的相对路径定位文件,并尽量移除直至存储根目录的空父目录。
|
||||
func (s *Service) Remove(stored StoredFile) error {
|
||||
absolute, err := s.absolutePath(stored.RelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.remove(absolute); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
root, err := filepath.Abs(s.root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for directory := filepath.Dir(absolute); directory != root; directory = filepath.Dir(directory) {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.remove(directory); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) absolutePath(relativePath string) (string, error) {
|
||||
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath)))
|
||||
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
|
||||
return "", ErrSourcePathRequired
|
||||
}
|
||||
root, err := filepath.Abs(s.root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.Join(root, cleaned))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
relativeToRoot, err := filepath.Rel(root, absolute)
|
||||
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
|
||||
return "", ErrSourcePathRequired
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func (s *Service) cleanupFailedSave(tempPath, finalPath string) {
|
||||
_ = s.remove(tempPath)
|
||||
_ = s.remove(finalPath)
|
||||
}
|
||||
|
||||
func (s *Service) database() *gorm.DB {
|
||||
if s.db != nil {
|
||||
return s.db
|
||||
|
||||
Reference in New Issue
Block a user