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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user