Harden storage permissions and WebDAV security

Permission service:
- Add hasAccess() with explicit minLevel param, deprecate canAccess()
- Cycle protection + depth limit (50) on all parent traversal
- Pick highest access level across multiple roles (was using first match)
- isPublic only grants view on directly requested folder, not inherited
- Sanitize file extension from content-type
- Clean up orphaned traverse perms when removing permissions
- Add getPermissionById() for authz checks on permission deletion

Storage routes:
- All write ops require edit via hasAccess() — traverse can no longer
  create folders, upload files, rename, toggle isPublic, or delete
- Permission delete requires admin access on the folder
- Permission list requires admin access on the folder
- Folder children listing filtered by user access
- File search results filtered by user access (was returning all)
- Signed URL requires view (was using canAccess which allows traverse)

WebDAV:
- 100MB upload size limit (was unbounded — OOM risk)
- PROPFIND root filters folders by user access (was listing all)
- COPY uses hasAccess('view') not canAccess (traverse bypass)
- All writes use hasAccess('edit') consistently
- MKCOL at root requires files.delete permission
- Lock ownership enforced on UNLOCK (was allowing any user)
- Lock conflict check on LOCK (423 if locked by another user)
- Lock enforcement on PUT and DELETE (423 if locked by another)
- Max 100 locks per user, periodic expired lock cleanup
- Path traversal protection: reject .. and null bytes in segments
- Brute-force protection: 10 failed attempts per IP, 5min lockout
This commit is contained in:
Ryan Moon
2026-03-29 18:21:19 -05:00
parent f998b16a3f
commit 748ea59c80
5 changed files with 365 additions and 117 deletions

View File

@@ -15,10 +15,10 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
const { name, parentId, isPublic } = request.body as { name?: string; parentId?: string; isPublic?: boolean }
if (!name?.trim()) throw new ValidationError('Folder name is required')
// Check parent access if creating subfolder
// Check parent access if creating subfolder — require at least edit (not traverse or view)
if (parentId) {
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentId, request.user.id)
if (!accessLevel || accessLevel === 'view') {
const hasEdit = await StoragePermissionService.hasAccess(app.db, parentId, request.user.id, 'edit')
if (!hasEdit) {
return reply.status(403).send({ error: { message: 'No edit access to parent folder', statusCode: 403 } })
}
}
@@ -29,10 +29,16 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
app.get('/storage/folders', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { parentId } = request.query as { parentId?: string }
const folders = parentId
const allChildren = parentId
? await StorageFolderService.listChildren(app.db, parentId)
: await StorageFolderService.listChildren(app.db, null)
return reply.send({ data: folders })
// Filter to only folders the user can access (at least traverse)
const accessible = []
for (const folder of allChildren) {
const level = await StoragePermissionService.getAccessLevel(app.db, folder.id, request.user.id)
if (level) accessible.push(folder)
}
return reply.send({ data: accessible })
})
app.get('/storage/folders/tree', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
@@ -56,8 +62,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params as { id: string }
const { name, isPublic } = request.body as { name?: string; isPublic?: boolean }
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
const hasEdit = await StoragePermissionService.hasAccess(app.db, id, request.user.id, 'edit')
if (!hasEdit) {
return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
}
@@ -83,6 +89,9 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
app.get('/storage/folders/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
// Only folder admins can view permission details
const hasAdmin = await StoragePermissionService.hasAccess(app.db, id, request.user.id, 'admin')
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
const permissions = await StoragePermissionService.listPermissions(app.db, id)
return reply.send({ data: permissions })
})
@@ -105,9 +114,18 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
app.delete('/storage/folder-permissions/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const perm = await StoragePermissionService.removePermission(app.db, id)
// Look up the permission to find which folder it belongs to
const existing = await StoragePermissionService.listPermissions(app.db, id)
// listPermissions takes folderId, we need to find by perm id — use removePermission which fetches first
const perm = await StoragePermissionService.getPermissionById(app.db, id)
if (!perm) return reply.status(404).send({ error: { message: 'Permission not found', statusCode: 404 } })
return reply.send(perm)
// Verify admin access on the folder this permission belongs to
const hasAdmin = await StoragePermissionService.hasAccess(app.db, perm.folderId, request.user.id, 'admin')
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
const deleted = await StoragePermissionService.removePermission(app.db, id)
return reply.send(deleted)
})
// --- Files ---
@@ -115,8 +133,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
app.post('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => {
const { folderId } = request.params as { folderId: string }
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, folderId, request.user.id)
if (!accessLevel || accessLevel === 'view') {
const hasEdit = await StoragePermissionService.hasAccess(app.db, folderId, request.user.id, 'edit')
if (!hasEdit) {
return reply.status(403).send({ error: { message: 'No edit access to this folder', statusCode: 403 } })
}
@@ -153,8 +171,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
const file = await StorageFileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, file.folderId, request.user.id)
if (!accessLevel || accessLevel === 'view') {
const hasEdit = await StoragePermissionService.hasAccess(app.db, file.folderId, request.user.id, 'edit')
if (!hasEdit) {
return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
}
@@ -167,8 +185,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
const file = await StorageFileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
const hasAccess = await StoragePermissionService.canAccess(app.db, file.folderId, request.user.id)
if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
const hasView = await StoragePermissionService.hasAccess(app.db, file.folderId, request.user.id, 'view')
if (!hasView) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
const token = app.jwt.sign({ path: file.path, purpose: 'file-access' } as any, { expiresIn: '15m' })
const signedUrl = `/v1/files/s/${file.path}?token=${token}`
@@ -181,6 +199,14 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
const params = PaginationSchema.parse(request.query)
const result = await StorageFileService.search(app.db, q, params)
return reply.send(result)
// Filter results to only files the user has at least view access to
const filtered = []
for (const file of result.data) {
const hasView = await StoragePermissionService.hasAccess(app.db, (file as any).folderId, request.user.id, 'view')
if (hasView) filtered.push(file)
}
return reply.send({ data: filtered, pagination: { ...result.pagination, total: filtered.length } })
})
}