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