mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 12:50:59 +00:00
Block unconfirmed users from API endpoints server-side (#2916)
Add a default-on email confirmation gate that rejects users with `requires_email_confirmation && !email_confirmed` on all authenticated routes, returning 403 with `email_confirmation_required`. Previously this was only enforced client-side via a GUI modal, meaning direct API calls could bypass the check entirely. Essential routes are exempted via `allowUnconfirmed: true`: `/whoami`, `/logout`, `/send-confirm-email`, `/confirm-email`, `/save_account`, `/get-anticsrf-token`, `/get-gui-token`, `/session/sync-cookie`, `/auth/revoke-session`, `/user-protected/delete-own-user` No impact on temp users (`requires_email_confirmation` is false), self-hosted deployments without email (flag is never set), or unauthenticated routes (login, signup, password recovery, OIDC). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
|
||||
extension.get(
|
||||
'/whoami',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{ subdomain: 'api', requireAuth: true, allowUnconfirmed: true },
|
||||
async (req, res) => {
|
||||
const actor = Context.get('actor');
|
||||
if (!actor?.user?.id) {
|
||||
|
||||
@@ -630,7 +630,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
router.post(
|
||||
'/logout',
|
||||
{ subdomain: ['api', ''], requireAuth: true, antiCsrf: true },
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
// Clear the session cookie
|
||||
res.clearCookie(this.config.cookie_name);
|
||||
@@ -670,6 +675,7 @@ export class AuthController extends PuterController {
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
rateLimit: {
|
||||
scope: 'send-confirm-email',
|
||||
limit: 10,
|
||||
@@ -716,6 +722,7 @@ export class AuthController extends PuterController {
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
rateLimit: {
|
||||
scope: 'confirm-email',
|
||||
limit: 10,
|
||||
@@ -1274,6 +1281,7 @@ export class AuthController extends PuterController {
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
captcha: true,
|
||||
rateLimit: {
|
||||
scope: 'save-account',
|
||||
@@ -1477,7 +1485,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
router.get(
|
||||
'/get-anticsrf-token',
|
||||
{ subdomain: '', requireAuth: true },
|
||||
{ subdomain: '', requireAuth: true, allowUnconfirmed: true },
|
||||
async (req, res) => {
|
||||
const sessionId = req.actor?.user?.uuid;
|
||||
if (!sessionId)
|
||||
@@ -1677,7 +1685,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-session',
|
||||
{ subdomain: 'api', requireUserActor: true, antiCsrf: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { uuid } = req.body;
|
||||
if (!uuid || typeof uuid !== 'string') {
|
||||
@@ -2241,7 +2254,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
router.get(
|
||||
'/get-gui-token',
|
||||
{ subdomain: ['api', ''], requireUserActor: true },
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!req.actor?.session?.uid)
|
||||
throw new HttpError(400, 'No session bound to this actor');
|
||||
@@ -2257,7 +2274,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
router.get(
|
||||
'/session/sync-cookie',
|
||||
{ subdomain: ['api', ''], requireUserActor: true },
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!req.actor?.session?.uid) {
|
||||
res.status(400).end();
|
||||
@@ -2293,6 +2314,7 @@ export class AuthController extends PuterController {
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
middleware: createUserProtectedGate(userProtectedDeps, {
|
||||
allowTempUsers: true,
|
||||
}),
|
||||
|
||||
@@ -49,8 +49,9 @@ const rejectAuth = (req: Request): HttpError => {
|
||||
* per-route `RouteOptions` and pushes the relevant gate(s) onto the express
|
||||
* middleware chain in this order:
|
||||
*
|
||||
* subdomain → requireAuth (+ suspended check) → requireUserActor →
|
||||
* adminOnly → allowedAppIds → caller middleware → handler
|
||||
* subdomain → requireAuth (+ suspended check) → emailConfirmed →
|
||||
* requireUserActor → adminOnly → allowedAppIds →
|
||||
* caller middleware → handler
|
||||
*
|
||||
* Each gate either calls `next()` to pass through, calls `next('route')` to
|
||||
* skip (subdomain only), or throws an `HttpError` for the terminal error
|
||||
@@ -198,6 +199,32 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
// ── requireEmailConfirmed ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reject authenticated users whose account is pending email confirmation
|
||||
* (`requires_email_confirmation && !email_confirmed`). Runs on every
|
||||
* authenticated route by default; routes that set `allowUnconfirmed: true`
|
||||
* skip this gate.
|
||||
*
|
||||
* Returns 403 with `email_confirmation_required` so clients can show the
|
||||
* confirmation prompt instead of a generic error.
|
||||
*/
|
||||
export const requireEmailConfirmedGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const user = req.actor?.user;
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
next(
|
||||
new HttpError(403, 'Please confirm your email to continue', {
|
||||
legacyCode: 'email_confirmation_required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// ── allowedAppIds ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,8 +57,9 @@ export type RoutePath = string | RegExp | Array<string | RegExp>;
|
||||
* The materializer (`v2/server.ts#materializeRoute`) translates these into a
|
||||
* middleware chain in this order:
|
||||
*
|
||||
* subdomain → requireAuth (+ suspended) → requireUserActor →
|
||||
* adminOnly → allowedAppIds → caller `middleware: []` → handler
|
||||
* subdomain → requireAuth (+ suspended) → emailConfirmed →
|
||||
* requireUserActor → adminOnly → allowedAppIds →
|
||||
* caller `middleware: []` → handler
|
||||
*
|
||||
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply
|
||||
* `requireAuth`; the materializer dedupes so only one auth gate ends up
|
||||
@@ -99,6 +100,19 @@ export interface RouteOptions {
|
||||
/** Reject unless the actor is acting through one of these apps. Implies `requireAuth`. */
|
||||
allowedAppIds?: string[];
|
||||
|
||||
/**
|
||||
* Allow users whose account is pending email confirmation to access
|
||||
* this route. By default, any authenticated route rejects users where
|
||||
* `requires_email_confirmation && !email_confirmed` with 403. Set
|
||||
* this to `true` on essential flows that must remain accessible
|
||||
* before confirmation: logout, email-confirm, whoami, save-account,
|
||||
* anti-CSRF token, etc.
|
||||
*
|
||||
* Only meaningful when the route also requires authentication (via
|
||||
* `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`).
|
||||
*/
|
||||
allowUnconfirmed?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's user has a confirmed email. 400 with
|
||||
* `account_is_not_verified` on failure. No-op when
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
adminOnlyGate,
|
||||
allowedAppIdsGate,
|
||||
requireAuthGate,
|
||||
requireEmailConfirmedGate,
|
||||
requireUserActorGate,
|
||||
requireVerifiedGate,
|
||||
subdomainGate,
|
||||
@@ -785,6 +786,15 @@ export class PuterServer {
|
||||
);
|
||||
if (needsAuth) mwChain.push(requireAuthGate());
|
||||
|
||||
// Default-on email confirmation gate. Every authenticated route
|
||||
// rejects users pending confirmation unless `allowUnconfirmed`
|
||||
// opts out. This prevents unconfirmed accounts from accessing
|
||||
// AI, FS, driver, etc. endpoints while still allowing essential
|
||||
// flows (logout, confirm-email, whoami, save-account, …).
|
||||
if (needsAuth && !opts.allowUnconfirmed) {
|
||||
mwChain.push(requireEmailConfirmedGate());
|
||||
}
|
||||
|
||||
// `requireVerified` intentionally does NOT imply `requireUserActor`:
|
||||
// FS routes (and similar) want the user's email to be confirmed even
|
||||
// when an app acts on the user's behalf. `requireVerifiedGate` reads
|
||||
|
||||
Reference in New Issue
Block a user