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))