Block unconfirmed users from API endpoints server-side (#2916)
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

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:
Nariman Jelveh
2026-05-05 10:08:50 -07:00
committed by GitHub
parent 51b81c82bf
commit 1f1149e32e
5 changed files with 83 additions and 10 deletions
+1 -1
View File
@@ -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) {
+27 -5
View File
@@ -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,
}),
+29 -2
View File
@@ -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 ───────────────────────────────────────────────────
/**
+16 -2
View File
@@ -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
+10
View File
@@ -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