feat: file tree api add path limit

This commit is contained in:
engigu
2026-02-10 14:12:15 +08:00
parent 43387709bc
commit 70a66c4693
5 changed files with 124 additions and 42 deletions
+103 -34
View File
@@ -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
}
+1
View File
@@ -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)
}
+5 -3
View File
@@ -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
}
+1
View File
@@ -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)
+14 -5
View File
@@ -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(() => {
<div class="space-y-1">
<Label class="text-xs">新名称</Label>
<Input v-model="newName" class="h-8 text-xs" placeholder="new_name.sh" @keyup.enter="renameItem" />
<p class="text-[10px] text-muted-foreground">仅支持修改名称不可包含路径分隔符 /</p>
</div>
</div>
<DialogFooter>