diff --git a/internal/controllers/file_controller.go b/internal/controllers/file_controller.go index 09009a7..ab83cc1 100644 --- a/internal/controllers/file_controller.go +++ b/internal/controllers/file_controller.go @@ -37,6 +37,28 @@ type FileNode struct { Children []*FileNode `json:"children,omitempty"` } +// checkPath verify if the path is inside the workDir and safe to use. +// It returns the full absolute path and a boolean indicating if it's safe. +func (fc *FileController) checkPath(path string, allowRoot bool) (string, bool) { + fullPath := filepath.Join(fc.workDir, filepath.Clean(path)) + rel, err := filepath.Rel(fc.workDir, fullPath) + if err != nil { + return "", false + } + + // Basic traversal check + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + + // Root directory check + if !allowRoot && rel == "." { + return "", false + } + + return fullPath, true +} + func (fc *FileController) GetFileTree(c *gin.Context) { root := &FileNode{ Name: filepath.Base(fc.workDir), @@ -104,8 +126,8 @@ func (fc *FileController) GetFileContent(c *gin.Context) { return } - fullPath := filepath.Join(fc.workDir, filepath.Clean(filePath)) - if !strings.HasPrefix(fullPath, fc.workDir) { + fullPath, safe := fc.checkPath(filePath, false) + if !safe { utils.Forbidden(c, "访问被拒绝") return } @@ -133,8 +155,8 @@ func (fc *FileController) SaveFileContent(c *gin.Context) { return } - fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path)) - if !strings.HasPrefix(fullPath, fc.workDir) { + fullPath, safe := fc.checkPath(req.Path, false) + if !safe { utils.Forbidden(c, "访问被拒绝") return } @@ -160,8 +182,8 @@ func (fc *FileController) CreateFile(c *gin.Context) { return } - fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path)) - if !strings.HasPrefix(fullPath, fc.workDir) { + fullPath, safe := fc.checkPath(req.Path, false) + if !safe { utils.Forbidden(c, "访问被拒绝") return } @@ -192,8 +214,8 @@ func (fc *FileController) DeleteFile(c *gin.Context) { return } - fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path)) - if !strings.HasPrefix(fullPath, fc.workDir) { + fullPath, safe := fc.checkPath(req.Path, false) + if !safe { utils.Forbidden(c, "访问被拒绝") return } @@ -206,6 +228,47 @@ func (fc *FileController) DeleteFile(c *gin.Context) { utils.SuccessMsg(c, "删除成功") } +func (fc *FileController) MoveFile(c *gin.Context) { + var req struct { + OldPath string `json:"oldPath" binding:"required"` + NewPath string `json:"newPath" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + oldFull, oldSafe := fc.checkPath(req.OldPath, false) + newFull, newSafe := fc.checkPath(req.NewPath, false) + + if !oldSafe || !newSafe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if oldFull == newFull { + utils.Success(c, nil) + return + } + + // 检查目标是否存在 + if _, err := os.Stat(newFull); err == nil { + utils.BadRequest(c, "目标已存在") + return + } + + // 确保目标目录存在 + os.MkdirAll(filepath.Dir(newFull), 0755) + + if err := os.Rename(oldFull, newFull); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, nil) +} + func (fc *FileController) RenameFile(c *gin.Context) { var req struct { OldPath string `json:"oldPath" binding:"required"` @@ -217,23 +280,37 @@ func (fc *FileController) RenameFile(c *gin.Context) { return } - oldFull := filepath.Join(fc.workDir, filepath.Clean(req.OldPath)) - newFull := filepath.Join(fc.workDir, filepath.Clean(req.NewPath)) + // 校验:重命名禁止跨目录 + if filepath.Dir(filepath.Clean(req.OldPath)) != filepath.Dir(filepath.Clean(req.NewPath)) { + utils.BadRequest(c, "禁止跨目录重命名") + return + } - if !strings.HasPrefix(oldFull, fc.workDir) || !strings.HasPrefix(newFull, fc.workDir) { + oldFull, oldSafe := fc.checkPath(req.OldPath, false) + newFull, newSafe := fc.checkPath(req.NewPath, false) + + if !oldSafe || !newSafe { utils.Forbidden(c, "访问被拒绝") return } - // 确保目标目录存在 - os.MkdirAll(filepath.Dir(newFull), 0755) + if oldFull == newFull { + utils.Success(c, nil) + return + } + + // 检查目标是否存在 + if _, err := os.Stat(newFull); err == nil { + utils.BadRequest(c, "文件已存在") + return + } if err := os.Rename(oldFull, newFull); err != nil { utils.ServerError(c, err.Error()) return } - utils.SuccessMsg(c, "移动成功") + utils.Success(c, nil) } // UploadArchive handles archive file upload and extraction @@ -254,13 +331,10 @@ func (fc *FileController) UploadArchive(c *gin.Context) { } // 确定解压目标目录 - extractDir := fc.workDir - if targetDir != "" { - extractDir = filepath.Join(fc.workDir, filepath.Clean(targetDir)) - if !strings.HasPrefix(extractDir, fc.workDir) { - utils.Forbidden(c, "访问被拒绝") - return - } + extractDir, safe := fc.checkPath(targetDir, true) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return } os.MkdirAll(extractDir, 0755) @@ -296,13 +370,10 @@ func (fc *FileController) UploadFiles(c *gin.Context) { targetDir := c.PostForm("path") // 确定目标目录 - destDir := fc.workDir - if targetDir != "" { - destDir = filepath.Join(fc.workDir, filepath.Clean(targetDir)) - if !strings.HasPrefix(destDir, fc.workDir) { - utils.Forbidden(c, "访问被拒绝") - return - } + destDir, safe := fc.checkPath(targetDir, true) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return } os.MkdirAll(destDir, 0755) @@ -328,10 +399,8 @@ func (fc *FileController) UploadFiles(c *gin.Context) { } // 构建完整路径 - fullPath := filepath.Join(destDir, filepath.Clean(relPath)) - - // 安全检查 - if !strings.HasPrefix(fullPath, fc.workDir) { + fullPath, safe := fc.checkPath(filepath.Join(targetDir, relPath), false) + if !safe { continue } @@ -355,8 +424,8 @@ func (fc *FileController) DownloadFile(c *gin.Context) { return } - fullPath := filepath.Join(fc.workDir, filepath.Clean(filePath)) - if !strings.HasPrefix(fullPath, fc.workDir) { + fullPath, safe := fc.checkPath(filePath, false) + if !safe { utils.Forbidden(c, "访问被拒绝") return } diff --git a/internal/router/router.go b/internal/router/router.go index a9f56a6..fc82949 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -160,6 +160,7 @@ func Setup(c *Controllers) *gin.Engine { files.POST("/create", c.File.CreateFile) files.POST("/delete", c.File.DeleteFile) files.POST("/rename", c.File.RenameFile) + files.POST("/move", c.File.MoveFile) files.POST("/upload", c.File.UploadArchive) files.POST("/uploadfiles", c.File.UploadFiles) } diff --git a/internal/utils/archive.go b/internal/utils/archive.go index d83496d..ded01bc 100644 --- a/internal/utils/archive.go +++ b/internal/utils/archive.go @@ -20,8 +20,9 @@ func ExtractZip(src, dest string) error { for _, f := range r.File { fpath := filepath.Join(dest, f.Name) - // 安全检查:防止路径遍历 - if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { + // 安全检查:防止路径遍历 (ZipSlip) + rel, err := filepath.Rel(dest, fpath) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { continue } @@ -95,7 +96,8 @@ func extractTarReader(tr *tar.Reader, dest string) error { fpath := filepath.Join(dest, header.Name) // 安全检查:防止路径遍历 - if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { + rel, err := filepath.Rel(dest, fpath) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { continue } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 21d0c27..dcc6b47 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -158,6 +158,7 @@ export const api = { create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }), delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }), rename: (oldPath: string, newPath: string) => request('/files/rename', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }), + move: (oldPath: string, newPath: string) => request('/files/move', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }), uploadArchive: async (file: File, targetPath?: string) => { const formData = new FormData() formData.append('file', file) diff --git a/web/src/views/editor/Editor.vue b/web/src/views/editor/Editor.vue index 7f2f83e..ced209d 100644 --- a/web/src/views/editor/Editor.vue +++ b/web/src/views/editor/Editor.vue @@ -199,6 +199,10 @@ async function renameItem() { toast.error('请输入名称') return } + if (newName.value.includes('/')) { + toast.error('名称不能包含路径分隔符 /') + return + } const parts = renamePath.value.split('/') parts[parts.length - 1] = newName.value const newPath = parts.join('/') @@ -209,7 +213,7 @@ async function renameItem() { } try { - await handleMove(renamePath.value, newPath, '重命名成功') + await handleMove(renamePath.value, newPath, '重命名成功', true) showRenameDialog.value = false } catch { // Error handled in handleMove @@ -268,9 +272,13 @@ async function handleDownload(path: string) { } } -async function handleMove(oldPath: string, newPath: string, successMsg = '移动成功') { +async function handleMove(oldPath: string, newPath: string, successMsg = '移动成功', isRename = false) { try { - await api.files.rename(oldPath, newPath) + if (isRename) { + await api.files.rename(oldPath, newPath) + } else { + await api.files.move(oldPath, newPath) + } toast.success(successMsg) if (selectedFile.value === oldPath) { selectedFile.value = newPath @@ -281,8 +289,8 @@ async function handleMove(oldPath: string, newPath: string, successMsg = '移动 router.replace({ name: 'editor', query: { file: newPath } }) } await loadTree() - } catch { - toast.error('移动失败') + } catch (err: any) { + toast.error(err.message || '移动失败') } } @@ -562,6 +570,7 @@ onUnmounted(() => {
注:仅支持修改名称,不可包含路径分隔符 /