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

@@ -30,6 +30,40 @@ function expandPermissions(slugs: string[]): Set<string> {
return expanded
}
// Simple rate limiter: track failed attempts per IP
const failedAttempts = new Map<string, { count: number; resetAt: number }>()
const MAX_FAILED_ATTEMPTS = 10
const LOCKOUT_DURATION = 5 * 60 * 1000 // 5 minutes
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const entry = failedAttempts.get(ip)
if (!entry || entry.resetAt < now) return true // allowed
return entry.count < MAX_FAILED_ATTEMPTS
}
function recordFailure(ip: string) {
const now = Date.now()
const entry = failedAttempts.get(ip)
if (!entry || entry.resetAt < now) {
failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION })
} else {
entry.count++
}
}
function clearFailures(ip: string) {
failedAttempts.delete(ip)
}
// Periodic cleanup of old entries
setInterval(() => {
const now = Date.now()
for (const [ip, entry] of failedAttempts) {
if (entry.resetAt < now) failedAttempts.delete(ip)
}
}, 60_000)
/**
* WebDAV Basic Auth pre-handler.
* Verifies HTTP Basic Auth credentials against the users table
@@ -37,6 +71,13 @@ function expandPermissions(slugs: string[]): Set<string> {
*/
export function webdavBasicAuth(app: FastifyInstance) {
return async function (request: FastifyRequest, reply: FastifyReply) {
const ip = request.ip
if (!checkRateLimit(ip)) {
reply.header('Retry-After', '300')
return reply.status(429).send('Too many failed attempts. Try again later.')
}
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Basic ')) {
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
@@ -46,6 +87,7 @@ export function webdavBasicAuth(app: FastifyInstance) {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8')
const colonIndex = decoded.indexOf(':')
if (colonIndex === -1) {
recordFailure(ip)
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Invalid credentials')
}
@@ -60,17 +102,20 @@ export function webdavBasicAuth(app: FastifyInstance) {
.limit(1)
if (!user || !user.isActive) {
recordFailure(ip)
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Invalid credentials')
}
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) {
recordFailure(ip)
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Invalid credentials')
}
// Attach user and permissions
// Success — clear failure counter
clearFailures(ip)
request.user = { id: user.id, role: user.role }
const permSlugs = await RbacService.getUserPermissions(app.db, user.id)
request.permissions = expandPermissions(permSlugs)

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

View File

@@ -12,10 +12,20 @@ const DAV_METHODS = 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL
const WEBDAV_PREFIX = '/webdav'
export const webdavRoutes: FastifyPluginAsync = async (app) => {
// Parse all request bodies as raw buffers for WebDAV
// Parse all request bodies as raw buffers for WebDAV, with size limit
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024 // 100MB
app.addContentTypeParser('*', function (_request, payload, done) {
const chunks: Buffer[] = []
payload.on('data', (chunk: Buffer) => chunks.push(chunk))
let totalSize = 0
payload.on('data', (chunk: Buffer) => {
totalSize += chunk.length
if (totalSize > MAX_UPLOAD_SIZE) {
payload.destroy()
done(new Error('Payload too large'), undefined)
return
}
chunks.push(chunk)
})
payload.on('end', () => done(null, Buffer.concat(chunks)))
payload.on('error', done)
})
@@ -85,7 +95,19 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
})
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
resources.push(...children)
// Filter to only folders the user has access to
const filtered = []
for (const child of children) {
if (!child.isCollection) continue // root has no files
// Extract folder name from href to resolve access
const folderName = decodeURIComponent(child.href.replace(`${WEBDAV_PREFIX}/`, '').replace(/\/$/, ''))
const resolved = await WebDavService.resolvePath(app.db, '/' + folderName)
if (resolved.type === 'folder' && resolved.folder) {
const level = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (level) filtered.push(child)
}
}
resources.push(...filtered)
}
} else if (resolved.type === 'folder' && resolved.folder) {
// Check access — traverse or higher lets you see the folder
@@ -144,7 +166,17 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
resources.push(...children)
const filtered = []
for (const child of children) {
if (!child.isCollection) continue
const folderName = decodeURIComponent(child.href.replace(`${WEBDAV_PREFIX}/`, '').replace(/\/$/, ''))
const childResolved = await WebDavService.resolvePath(app.db, '/' + folderName)
if (childResolved.type === 'folder' && childResolved.folder) {
const level = await StoragePermissionService.getAccessLevel(app.db, childResolved.folder.id, request.user.id)
if (level) filtered.push(child)
}
}
resources.push(...filtered)
}
const xml = buildMultistatus(resources)
@@ -189,15 +221,19 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
if (!name) return reply.status(400).send('Invalid path')
// Check if resource is locked by another user
const lockCheck = checkLock(resourcePath, request.user.id)
if (lockCheck.byOther) return reply.status(423).send('Resource is locked by another user')
// Resolve parent to find the folder
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
return reply.status(409).send('Parent folder not found')
}
// Check edit permission
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
// Check edit permission — traverse and view are not enough
const hasEdit = await StoragePermissionService.hasAccess(app.db, parentResolved.folder.id, request.user.id, 'edit')
if (!hasEdit) {
return reply.status(403).send('No edit access')
}
@@ -227,11 +263,16 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
// --- DELETE ---
app.delete('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
// Check if resource is locked by another user
const lockCheck = checkLock(resourcePath, request.user.id)
if (lockCheck.byOther) return reply.status(423).send('Resource is locked by another user')
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') return reply.status(403).send('No edit access')
const hasEdit = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'edit')
if (!hasEdit) return reply.status(403).send('No edit access')
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
return reply.status(204).send('')
}
@@ -268,12 +309,16 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
return reply.status(409).send('Parent folder not found')
}
// Check edit permission on parent
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
const hasEdit = await StoragePermissionService.hasAccess(app.db, parentResolved.folder.id, request.user.id, 'edit')
if (!hasEdit) {
return reply.status(403).send('No edit access to parent folder')
}
parentFolderId = parentResolved.folder.id
} else {
// Creating top-level folder requires files.admin permission
if (!request.permissions.has('files.delete')) {
return reply.status(403).send('Admin permission required to create top-level folders')
}
}
await StorageFolderService.create(app.db, {
@@ -308,9 +353,9 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
return reply.status(404).send('Source not found (only file copy supported)')
}
// Check read access on source
const srcAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!srcAccess) return reply.status(403).send('No access to source')
// Check view access on source (traverse is not enough to read file data)
const hasSrcView = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'view')
if (!hasSrcView) return reply.status(403).send('No access to source')
// Resolve destination parent
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
@@ -320,8 +365,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
}
// Check edit access on destination
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
const hasDestEdit = await StoragePermissionService.hasAccess(app.db, destParent.folder.id, request.user.id, 'edit')
if (!hasDestEdit) return reply.status(403).send('No edit access to destination')
// Copy: read source file data and upload to destination
const fileData = await app.storage.get(resolved.file.path)
@@ -366,8 +411,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
if (resolved.type === 'file' && resolved.file && resolved.folder) {
// Check edit access on source folder
const srcAccess = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (!srcAccess || srcAccess === 'view') return reply.status(403).send('No edit access to source')
const hasSrcEdit = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'edit')
if (!hasSrcEdit) return reply.status(403).send('No edit access to source')
// Resolve destination
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
@@ -375,8 +420,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
if (destParent.type !== 'folder' || !destParent.folder) {
return reply.status(409).send('Destination parent not found')
}
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
const hasDestEdit = await StoragePermissionService.hasAccess(app.db, destParent.folder.id, request.user.id, 'edit')
if (!hasDestEdit) return reply.status(403).send('No edit access to destination')
// Move: copy data then delete source
const overwrite = (request.headers['overwrite'] as string)?.toUpperCase() !== 'F'
@@ -412,8 +457,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
if (destParent.type === 'folder' || destParent.type === 'root') {
const newParentId = destParent.type === 'folder' ? destParent.folder?.id : undefined
if (newParentId) {
const destAccess = await StoragePermissionService.getAccessLevel(app.db, newParentId, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
const hasDestEdit = await StoragePermissionService.hasAccess(app.db, newParentId, request.user.id, 'edit')
if (!hasDestEdit) return reply.status(403).send('No edit access to destination')
}
await StorageFolderService.update(app.db, resolved.folder.id, { name: destName })
@@ -428,6 +473,23 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
},
})
// --- Lock helpers ---
const MAX_LOCKS_PER_USER = 100
const LOCK_TIMEOUT = 300 // 5 minutes
function cleanExpiredLocks() {
const now = Date.now()
for (const [path, lock] of locks) {
if (lock.expires < now) locks.delete(path)
}
}
function checkLock(resourcePath: string, userId: string): { locked: boolean; byOther: boolean } {
const lock = locks.get(resourcePath)
if (!lock || lock.expires < Date.now()) return { locked: false, byOther: false }
return { locked: true, byOther: lock.owner !== userId }
}
// --- LOCK ---
app.route({
method: 'LOCK' as any,
@@ -435,22 +497,27 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const timeout = 300 // 5 minutes
const token = `opaquelocktoken:${randomUUID()}`
cleanExpiredLocks()
// Clean expired locks
const now = Date.now()
for (const [path, lock] of locks) {
if (lock.expires < now) locks.delete(path)
// Check if locked by another user
const existing = locks.get(resourcePath)
if (existing && existing.expires > Date.now() && existing.owner !== request.user.id) {
return reply.status(423).send('Resource is locked by another user')
}
locks.set(resourcePath, {
token,
owner: request.user.id,
expires: now + timeout * 1000,
})
// Cap locks per user
let userLockCount = 0
for (const lock of locks.values()) {
if (lock.owner === request.user.id && lock.expires > Date.now()) userLockCount++
}
if (userLockCount >= MAX_LOCKS_PER_USER) {
return reply.status(400).send('Too many active locks')
}
const xml = buildLockResponse(token, request.user.id, timeout)
const token = `opaquelocktoken:${randomUUID()}`
locks.set(resourcePath, { token, owner: request.user.id, expires: Date.now() + LOCK_TIMEOUT * 1000 })
const xml = buildLockResponse(token, request.user.id, LOCK_TIMEOUT)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.header('Lock-Token', `<${token}>`)
@@ -459,15 +526,15 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
},
})
// LOCK on root
app.route({
method: 'LOCK' as any,
url: '/',
preHandler: auth,
handler: async (request, reply) => {
cleanExpiredLocks()
const token = `opaquelocktoken:${randomUUID()}`
const xml = buildLockResponse(token, request.user.id, 300)
locks.set('/', { token, owner: request.user.id, expires: Date.now() + 300000 })
locks.set('/', { token, owner: request.user.id, expires: Date.now() + LOCK_TIMEOUT * 1000 })
const xml = buildLockResponse(token, request.user.id, LOCK_TIMEOUT)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.header('Lock-Token', `<${token}>`)
@@ -483,6 +550,11 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const lock = locks.get(resourcePath)
// Only the lock owner can unlock
if (lock && lock.owner !== request.user.id) {
return reply.status(403).send('Not the lock owner')
}
locks.delete(resourcePath)
return reply.status(204).send('')
},
@@ -492,7 +564,11 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
method: 'UNLOCK' as any,
url: '/',
preHandler: auth,
handler: async (_request, reply) => {
handler: async (request, reply) => {
const lock = locks.get('/')
if (lock && lock.owner !== request.user.id) {
return reply.status(403).send('Not the lock owner')
}
locks.delete('/')
return reply.status(204).send('')
},

View File

@@ -7,6 +7,14 @@ import { randomUUID } from 'crypto'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
import type { PaginationInput } from '@forte/shared/schemas'
const MAX_PARENT_DEPTH = 50
const ACCESS_RANK: Record<string, number> = { traverse: 0, view: 1, edit: 2, admin: 3 }
function highestAccess(a: string, b: string): 'admin' | 'edit' | 'view' | 'traverse' {
return (ACCESS_RANK[a] ?? 0) >= (ACCESS_RANK[b] ?? 0) ? a as any : b as any
}
function getExtension(contentType: string): string {
const map: Record<string, string> = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp',
@@ -16,75 +24,87 @@ function getExtension(contentType: string): string {
'application/msword': 'doc', 'application/vnd.ms-excel': 'xls',
'text/plain': 'txt', 'text/csv': 'csv',
}
return map[contentType] ?? contentType.split('/')[1] ?? 'bin'
// Sanitize: only allow alphanumeric extensions
const fallback = (contentType.split('/')[1] ?? 'bin').replace(/[^a-zA-Z0-9]/g, '')
return map[contentType] ?? (fallback || 'bin')
}
// --- Permission Service ---
export const StoragePermissionService = {
/**
* Check if a user has at least the given minimum access level on a folder.
* Use this instead of canAccess() to enforce traverse vs view distinction.
*/
async hasAccess(db: PostgresJsDatabase<any>, folderId: string, userId: string, minLevel: 'traverse' | 'view' | 'edit' | 'admin' = 'view'): Promise<boolean> {
const level = await this.getAccessLevel(db, folderId, userId)
if (!level) return false
return ACCESS_RANK[level] >= ACCESS_RANK[minLevel]
},
/**
* @deprecated Use hasAccess() with explicit minLevel instead.
* canAccess returns true for traverse, which may not be intended.
*/
async canAccess(db: PostgresJsDatabase<any>, folderId: string, userId: string): Promise<boolean> {
// Check if folder is public or user is creator
const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (!folder) return false
if (folder.isPublic) return true
if (folder.createdBy === userId) return true
// Check direct user permission
const [userPerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions)
.where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId)))
.limit(1)
if (userPerm) return true
// Check role-based permission
const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId))
if (userRoleIds.length > 0) {
const roleIds = userRoleIds.map((r) => r.roleId)
const [rolePerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions)
.where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds)))
.limit(1)
if (rolePerm) return true
}
// Check parent folder (inherited permissions)
const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (parentFolder?.parentId) {
return this.canAccess(db, parentFolder.parentId, userId)
}
return false
const level = await this.getAccessLevel(db, folderId, userId)
return level !== null
},
async getAccessLevel(db: PostgresJsDatabase<any>, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> {
const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
const visited = new Set<string>()
return this._getAccessLevelInner(db, folderId, userId, visited, false)
},
/**
* Inner recursive access level resolver with cycle protection and depth limit.
* isInherited: true when recursing into parent folders — isPublic only applies to the directly requested folder.
*/
async _getAccessLevelInner(
db: PostgresJsDatabase<any>, folderId: string, userId: string,
visited: Set<string>, isInherited: boolean,
): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> {
if (visited.has(folderId) || visited.size >= MAX_PARENT_DEPTH) return null
visited.add(folderId)
const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy, parentId: storageFolders.parentId })
.from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (!folder) return null
// Creator always has admin
if (folder.createdBy === userId) return 'admin'
// Collect the best explicit permission on this folder
let best: 'admin' | 'edit' | 'view' | 'traverse' | null = null
// Check direct user permission
const [userPerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
.where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId)))
.limit(1)
if (userPerm) return userPerm.accessLevel
if (userPerm) best = userPerm.accessLevel
// Check role-based permission
const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId))
if (userRoleIds.length > 0) {
const roleIds = userRoleIds.map((r) => r.roleId)
const [rolePerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
// Check role-based permissions — pick highest across all roles
const userRoleRows = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId))
if (userRoleRows.length > 0) {
const roleIds = userRoleRows.map((r) => r.roleId)
const rolePerms = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
.where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds)))
.limit(1)
if (rolePerm) return rolePerm.accessLevel
for (const rp of rolePerms) {
best = best ? highestAccess(best, rp.accessLevel) : rp.accessLevel
}
}
// Check parent (inherited)
const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (parentFolder?.parentId) {
return this.getAccessLevel(db, parentFolder.parentId, userId)
if (best) return best
// Check parent (inherited permissions) — only explicit permissions inherit, not isPublic
if (folder.parentId) {
const inherited = await this._getAccessLevelInner(db, folder.parentId, userId, visited, true)
if (inherited) return inherited
}
// Public folders give view access
if (folder.isPublic) return 'view'
// isPublic only grants view on the directly requested folder, not via inheritance
// This prevents a public grandparent from granting view on a private child
if (!isInherited && folder.isPublic) return 'view'
return null
},
@@ -93,6 +113,11 @@ export const StoragePermissionService = {
return db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.folderId, folderId))
},
async getPermissionById(db: PostgresJsDatabase<any>, permissionId: string) {
const [perm] = await db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).limit(1)
return perm ?? null
},
async setPermission(db: PostgresJsDatabase<any>, folderId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) {
// Remove existing permission for this role/user on this folder
if (roleId) {
@@ -125,9 +150,11 @@ export const StoragePermissionService = {
const [folder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (!folder?.parentId) return
const visited = new Set<string>()
let currentParentId: string | null = folder.parentId
while (currentParentId) {
// Check if this role/user already has any permission on this ancestor
while (currentParentId && !visited.has(currentParentId) && visited.size < MAX_PARENT_DEPTH) {
visited.add(currentParentId)
const whereClause = roleId
? and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.roleId, roleId))
: and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.userId, userId!))
@@ -135,7 +162,6 @@ export const StoragePermissionService = {
const [existing] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions).where(whereClause).limit(1)
if (!existing) {
// No permission — add traverse
await db.insert(storageFolderPermissions).values({
folderId: currentParentId,
roleId: roleId ?? null,
@@ -143,17 +169,94 @@ export const StoragePermissionService = {
accessLevel: 'traverse' as any,
})
}
// If they already have any permission (even traverse), don't touch it
// Walk up
const [parent] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentParentId)).limit(1)
currentParentId = parent?.parentId ?? null
}
},
async removePermission(db: PostgresJsDatabase<any>, permissionId: string) {
const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning()
return perm ?? null
// Fetch before deleting so we can clean up ancestors
const [perm] = await db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).limit(1)
if (!perm) return null
const [deleted] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning()
// Clean up orphaned traverse permissions on ancestors
await this.cleanupTraverseAncestors(db, perm.folderId, perm.roleId ?? undefined, perm.userId ?? undefined)
return deleted ?? null
},
/**
* After removing a permission, check if traverse grants on ancestors are still needed.
* A traverse permission is orphaned if the user/role has no other permissions on the
* folder itself or any of its descendants.
*/
async cleanupTraverseAncestors(db: PostgresJsDatabase<any>, folderId: string, roleId: string | undefined, userId: string | undefined) {
const [folder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (!folder?.parentId) return
const visited = new Set<string>()
let currentId: string | null = folder.parentId
while (currentId && !visited.has(currentId) && visited.size < MAX_PARENT_DEPTH) {
visited.add(currentId)
// Find the traverse permission for this ancestor
const whereClause = roleId
? and(eq(storageFolderPermissions.folderId, currentId), eq(storageFolderPermissions.roleId, roleId), eq(storageFolderPermissions.accessLevel, 'traverse'))
: and(eq(storageFolderPermissions.folderId, currentId), eq(storageFolderPermissions.userId, userId!), eq(storageFolderPermissions.accessLevel, 'traverse'))
const [traversePerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions).where(whereClause).limit(1)
if (!traversePerm) break // No traverse to clean, stop walking up
// Check if there are any other (non-traverse) permissions for this user/role on descendants
// Use materialized path prefix to find all descendants efficiently
const [currentFolder] = await db.select({ path: storageFolders.path, name: storageFolders.name }).from(storageFolders).where(eq(storageFolders.id, currentId)).limit(1)
if (!currentFolder) break
const descendantPath = `${currentFolder.path}${currentFolder.name}/`
const descendants = await db.select({ id: storageFolders.id }).from(storageFolders)
.where(ilike(storageFolders.path, `${descendantPath}%`))
const descendantIds = descendants.map((d) => d.id)
// Also include the folder itself
descendantIds.push(currentId)
const permWhereBase = roleId
? eq(storageFolderPermissions.roleId, roleId)
: eq(storageFolderPermissions.userId, userId!)
let hasOtherPerms = false
if (descendantIds.length > 0) {
const [otherPerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions)
.where(and(
inArray(storageFolderPermissions.folderId, descendantIds),
permWhereBase,
// Exclude the traverse perm we're considering removing
// Look for any non-traverse permission
))
.limit(2) // We need to check if there's more than just the traverse perm itself
// Count how many perms exist - if only the traverse perm on this folder, it's orphaned
const allPerms = await db.select({ id: storageFolderPermissions.id, accessLevel: storageFolderPermissions.accessLevel, folderId: storageFolderPermissions.folderId })
.from(storageFolderPermissions)
.where(and(inArray(storageFolderPermissions.folderId, descendantIds), permWhereBase))
hasOtherPerms = allPerms.some((p) => p.id !== traversePerm.id)
}
if (!hasOtherPerms) {
// Orphaned — remove the traverse permission
await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, traversePerm.id))
} else {
break // Still needed, stop walking up
}
// Walk up
const [parent] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentId)).limit(1)
currentId = parent?.parentId ?? null
}
},
}
@@ -189,18 +292,11 @@ export const StorageFolderService = {
},
async listAllAccessible(db: PostgresJsDatabase<any>, userId: string) {
// Get all folders and filter by access — for the tree view
const allFolders = await db.select().from(storageFolders).orderBy(storageFolders.name)
// For each folder, check access (this is simplified — in production you'd optimize with a single query)
const accessible = []
for (const folder of allFolders) {
if (folder.isPublic || folder.createdBy === userId) {
accessible.push(folder)
continue
}
const hasAccess = await StoragePermissionService.canAccess(db, folder.id, userId)
if (hasAccess) accessible.push(folder)
const level = await StoragePermissionService.getAccessLevel(db, folder.id, userId)
if (level) accessible.push(folder)
}
return accessible
},

View File

@@ -18,6 +18,11 @@ export const WebDavService = {
async resolvePath(db: PostgresJsDatabase<any>, urlPath: string): Promise<ResolvedPath> {
const segments = urlPath.split('/').filter(Boolean).map(decodeURIComponent)
// Reject path traversal and null bytes
if (segments.some(s => s === '.' || s === '..' || s.includes('\0'))) {
return { type: null }
}
if (segments.length === 0) {
return { type: 'root' }
}