diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts index 3eeb0a706..6ecee404c 100644 --- a/src/backend/controllers/webdav/WebDAVController.test.ts +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -335,6 +335,63 @@ describe('WebDAVController', () => { }); }); + describe('suspension gate', () => { + // WebDAV must also enforce the suspension gate every other authenticated + // route gets from requireAuthGate. Same single-use() dispatch means that + // middleware is never wired in, so #dispatch calls assertNotSuspended + // itself. Without it, a suspended account could read/write/delete its + // whole filesystem over the `dav` subdomain. + it('rejects a suspended session actor with 403', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + actor: { + user: { + id: 1, + uuid: 'suspended-uuid', + username: 'suspended', + suspended: true, + }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(403); + }); + + it('rejects a suspended user on the Basic-auth path with 403', async () => { + const username = `webdav-suspended-${Math.random() + .toString(36) + .slice(2, 10)}`; + const created = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: await bcryptHash('correct-horse', 4), + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + await server.stores.user.update(created.id, { + suspended: 1, + }); + + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + headers: { + authorization: basicAuth(username, 'correct-horse'), + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(403); + }); + }); + describe('unsupported methods', () => { it('returns 405 for unknown HTTP methods', async () => { const { res, captured } = makeRes(); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index 0036d6ffc..bb9b131eb 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -23,7 +23,10 @@ import { posix as pathPosix } from 'node:path'; import { EventMap } from '../../clients/event/types.js'; import type { Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; -import { assertVerifiedAccount } from '../../core/http/middleware/gates.js'; +import { + assertNotSuspended, + assertVerifiedAccount, +} from '../../core/http/middleware/gates.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { verify as verifyOtp } from '../../services/auth/OTPUtil.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; @@ -90,14 +93,15 @@ export class WebDAVController extends PuterController { const actor = await this.#resolveActor(req, res); if (!actor) return; // 401 already sent - // Apply the same pending-verification gate every other authenticated - // route gets from `requireVerifiedAccount`. WebDAV dispatches every - // method off a single `router.use` with no route options, so that - // middleware is never inserted into its chain — without this call an - // account still pending email / phone / card verification could read, - // write, and delete its entire filesystem over the `dav` subdomain, - // bypassing the gate. Throws a 403 HttpError, surfaced by the catch in - // registerRoutes. + // Apply the same suspension + pending-verification gates every other + // authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`. + // WebDAV dispatches every method off a single `router.use` with no route + // options, so that middleware is never inserted into its chain — without + // these calls a suspended account (or one still pending email / phone / + // card verification) could read, write, and delete its entire filesystem + // over the `dav` subdomain, bypassing the gates. Both throw a 403 + // HttpError, surfaced by the catch in registerRoutes. + assertNotSuspended(actor.user); assertVerifiedAccount(actor.user); // Expand `~`/`~/...` against the authenticated actor's username. diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index b71edc146..9f75a6546 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -93,12 +93,10 @@ export const requireAuthGate = (): RequestHandler => { next(rejectAuth(req)); return; } - if (req.actor.user.suspended) { - next( - new HttpError(403, 'Account suspended', { - legacyCode: 'forbidden', - }), - ); + try { + assertNotSuspended(req.actor.user); + } catch (err) { + next(err); return; } next(); @@ -312,6 +310,16 @@ export const assertVerifiedAccount = ( } }; +export const assertNotSuspended = ( + user: { suspended?: unknown } | undefined, +): void => { + if (user?.suspended) { + throw new HttpError(403, 'Account suspended', { + legacyCode: 'forbidden', + }); + } +}; + /** * Reject unless the actor is acting through one of the named apps. * App-under-user actors are permitted iff `actor.app.uid` is in the allowList;