@samitouri / QOSami-HFS / commits / 57b33820

fix: prevent rename of root, even with permission #991

Massimo Melina committed May 8, 2025 at 17:48 UTC 57b33820c3885d87831468559ad6c0c584df301a
5 files changed +23 -15
frontend/src/Breadcrumbs.ts
+2 -2
@@ -45,10 +45,10 @@ function Breadcrumb({ path, label, current, ...rest }: { current?: boolean, path
45 to: path || '/',
46 ...!current && dragFilesDestination, // we don't really know if this folder allows upload, but in the worst case the user will get an error
47 ...rest,
48 - async onClick(ev) {
48 + onClick(ev) {
49 if (!current) return
50 ev.preventDefault()
51 - openFileMenu(new DirEntry(decodeURIComponent(path), { p }), ev, [
51 + void openFileMenu(new DirEntry(decodeURIComponent(path), { p }), ev, [
52 props?.can_upload && {
53 id: 'create-folder',
54 label: t`Create folder`,
frontend/src/state.ts
+5 -3
@@ -129,7 +129,9 @@ export class DirEntry implements ServerDirEntry {
129 const x = this.isFolder && !this.web ? 'L' : 'R' // to open we need list for folders and read for files
130 this.cantOpen = this.p?.match(x) ? true : this.p?.match(x.toLowerCase()) ? DirEntry.FORBIDDEN : undefined
131 }
132 -
132 + isRoot() {
133 + return !this.name
134 + }
135 getNext() {
136 return this.getSibling(+1)
137 }
@@ -154,13 +156,13 @@ export class DirEntry implements ServerDirEntry {
156 return this.p?.includes('A') || state.props?.can_archive && !this.p?.includes('a')
157 }
158 canDelete() {
157 - return this.p?.includes('D') || state.props?.can_delete && !this.p?.includes('d')
159 + return !this.isRoot() && (this.p?.includes('D') || state.props?.can_delete && !this.p?.includes('d'))
160 }
161 canUpload() {
162 return this.isFolder && (this.p?.includes('U') || state.props?.can_upload && !this.p?.includes('u'))
163 }
164 canSelect() {
163 - if (this.url) return false
165 + if (this.url || this.isRoot()) return false
166 return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins
167 || hfsEvent('enableEntrySelection', { entry: this }).some(Boolean)
168 }
src/api.vfs.ts
+3 -3
@@ -2,7 +2,7 @@
2
3 import {
4 getNodeName, isSameFilenameAs, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
5 - permsFromParent, nodeIsLink, VfsNodeStored
5 + permsFromParent, nodeIsLink, VfsNodeStored, isRoot
6 } from './vfs'
7 import _ from 'lodash'
8 import { mkdir, stat } from 'fs/promises'
@@ -58,7 +58,7 @@ const apis: ApiHandlers = {
58 website: Boolean(node.children?.find(isSameFilenameAs('index.html')))
59 || isDir && source && await stat(join(source, 'index.html')).then(() => true, () => undefined)
60 || undefined,
61 - name: node === vfs ? '' : getNodeName(node),
61 + name: getNodeName(node),
62 type: isDir ? 'folder' : undefined,
63 children: node.children && await Promise.all(node.children.map(async child =>
64 recur(await applyParentToChild(child, node)) ))
@@ -72,7 +72,7 @@ const apis: ApiHandlers = {
72 const fromNode = await urlToNodeOriginal(from)
73 if (!fromNode)
74 return new ApiError(HTTP_NOT_FOUND, 'from not found')
75 - if (fromNode === vfs)
75 + if (isRoot(fromNode))
76 return new ApiError(HTTP_BAD_REQUEST, 'from is root')
77 if (parent.startsWith(from))
78 return new ApiError(HTTP_BAD_REQUEST, 'incompatible parent')
src/frontEndApis.ts
+3 -3
@@ -11,7 +11,7 @@ import {
11 HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED
12 } from './const'
13 import {
14 - hasPermission, nodeIsDirectory, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
14 + hasPermission, isRoot, nodeIsDirectory, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
15 } from './vfs'
16 import fs from 'fs'
17 import { mkdir, rename, copyFile, unlink } from 'fs/promises'
@@ -83,11 +83,11 @@ export const frontEndApis: ApiHandlers = {
83 async rename({ uri, dest }, ctx) {
84 apiAssertTypes({ string: { uri, dest } })
85 ctx.logExtra(null, { target: decodeURI(uri), destination: decodeURI(dest) })
86 - if (dest.includes('/') || dirTraversal(dest))
87 - throw new ApiError(HTTP_FORBIDDEN)
86 const node = await urlToNode(uri, ctx)
87 if (!node)
88 throw new ApiError(HTTP_NOT_FOUND)
89 + if (isRoot(node) || dest.includes('/') || dirTraversal(dest))
90 + throw new ApiError(HTTP_FORBIDDEN)
91 if (!hasPermission(node, 'can_delete', ctx))
92 throw new ApiError(HTTP_UNAUTHORIZED)
93 try {
src/vfs.ts
+10 -4
@@ -190,12 +190,18 @@ export function saveVfs() {
190 return setConfig({ vfs: _.cloneDeep(vfs) }, true)
191 }
192
193 +export function isRoot(node: VfsNode) {
194 + return node === vfs
195 +}
196 +
197 export function getNodeName(node: VfsNode) {
194 - const { name, source } = node
195 - if (name)
196 - return name
198 + if (isRoot(node))
199 + return ''
200 + if (node.name)
201 + return node.name
202 + const { source } = node
203 if (!source)
198 - return '' // should happen only for root
204 + return '' // shoulnd't happen
205 if (source === '/')
206 return 'root' // better name than
207 if (/^[a-zA-Z]:\\?$/.test(source))