Check if user is suspended on WebDAV basic auth and others (#3369)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Neal Shah
2026-07-13 02:46:28 -04:00
committed by GitHub
parent b957f797da
commit 2c139929ac
3 changed files with 84 additions and 15 deletions
@@ -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();
@@ -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.
+14 -6
View File
@@ -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;