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

@@ -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' }
}