feat: lift 5-account cap on HTTP/2

This commit is contained in:
Linus Rath
2026-05-07 12:28:33 +02:00
parent bd72dec98f
commit 5f464d4ee2
12 changed files with 77 additions and 31 deletions
+5 -4
View File
@@ -13,6 +13,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
...getCookieOptions(), ...getCookieOptions(),
@@ -23,7 +24,7 @@ function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -70,7 +71,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
@@ -185,8 +186,8 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Delete all session cookies (slots 0-4) // Delete all session cookies across every slot.
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i); clearStalwartAuthContextInStore(cookieStore, i);
} }
+3 -2
View File
@@ -6,9 +6,10 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest, bodySlot: unknown): number { function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
return bodySlot; return bodySlot;
} }
@@ -16,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot; return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? 0 : slot;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
+5 -4
View File
@@ -4,12 +4,13 @@ import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
@@ -112,9 +113,9 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Revoke and delete all refresh token cookies (slots 0-4) // Revoke and delete all refresh token cookies across every slot.
const cookieStore = await cookies(); const cookieStore = await cookies();
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
const name = refreshTokenCookieName(i); const name = refreshTokenCookieName(i);
const serverCookieName = refreshTokenServerCookieName(i); const serverCookieName = refreshTokenServerCookieName(i);
const token = cookieStore.get(name)?.value; const token = cookieStore.get(name)?.value;
+2 -1
View File
@@ -9,6 +9,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers'; import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Pin the upstream URL to a configured JMAP server. The list of allowed // Pin the upstream URL to a configured JMAP server. The list of allowed
+3 -2
View File
@@ -7,6 +7,7 @@ import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -59,7 +60,7 @@ function normalizeUrl(url: string): string {
/** /**
* Verify identity against session cookies across all account slots. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies * Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions). * (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found. * Returns true only if a matching cookie is found.
@@ -68,7 +69,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
const cookieStore = await cookies(); const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl); const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie // Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) { if (token) {
+2 -2
View File
@@ -6,7 +6,7 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
@@ -220,7 +220,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
</div> </div>
{/* Separator + Add Account */} {/* Separator + Add Account */}
{accounts.length < MAX_ACCOUNTS && ( {accounts.length < getMaxAccounts() && (
<div className="border-t border-border"> <div className="border-t border-border">
<button <button
onClick={handleAddAccount} onClick={handleAddAccount}
+2 -2
View File
@@ -18,7 +18,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -634,7 +634,7 @@ export function NavigationRail({
</button> </button>
); );
})} })}
{accounts.length < MAX_ACCOUNTS && ( {accounts.length < getMaxAccounts() && (
<button <button
onClick={() => router.push(`/login?mode=add-account` as never)} onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0" className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
+40 -2
View File
@@ -62,5 +62,43 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
return `${baseKey}::${accountId}`; return `${baseKey}::${accountId}`;
} }
/** Maximum number of accounts allowed */ /**
export const MAX_ACCOUNTS = 5; * Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
* cookies on average — within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
/**
* UX cap for browsers using HTTP/1.1. Each account holds one persistent
* SSE connection for JMAP push; HTTP/1.1 caps origins at 6 concurrent
* connections, so 5 accounts leave one connection free for normal traffic.
* On HTTP/2+ this cap doesn't apply because streams are multiplexed.
*/
export const MAX_ACCOUNTS_HTTP1 = 5;
/**
* Detect whether the page has observed any HTTP/2 or HTTP/3 traffic.
*
* We walk recent resource-timing entries and treat a single h2/h3 sighting
* as a positive signal. Cross-origin entries may report an empty
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
* under-detect and fall back to the conservative cap — that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
for (let i = entries.length - 1; i >= 0; i--) {
const proto = entries[i].nextHopProtocol;
if (proto === 'h2' || proto === 'h3') return true;
}
return false;
}
/**
* Effective per-browser account cap. Lifts to {@link MAX_ACCOUNT_SLOTS}
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export const SESSION_COOKIE = 'jmap_session'; export const SESSION_COOKIE = 'jmap_session';
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ /** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function sessionCookieName(slot: number): string { export function sessionCookieName(slot: number): string {
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`; return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAU
export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts'; export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */ /** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function refreshTokenCookieName(slot: number): string { export function refreshTokenCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`; return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
} }
+5 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { sessionCookieName } from '@/lib/auth/session-cookie'; import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
export interface StalwartCredentials { export interface StalwartCredentials {
/** URL of the JMAP server (used for JMAP + management method calls) */ /** URL of the JMAP server (used for JMAP + management method calls) */
@@ -15,14 +16,16 @@ export interface StalwartCredentials {
function parseSlot(raw: string | null): number | null { function parseSlot(raw: string | null): number | null {
if (raw === null) return null; if (raw === null) return null;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot; return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? null : slot;
} }
const ALL_SLOTS = Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i);
function getCandidateSlots(request: NextRequest): number[] { function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot')) const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot')); ?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot]; return requestedSlot === null ? ALL_SLOTS : [requestedSlot];
} }
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> { export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
+8 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
export interface AccountEntry { export interface AccountEntry {
/** Unique key: `${username}@${serverHostname}` */ /** Unique key: `${username}@${serverHostname}` */
@@ -13,7 +13,7 @@ export interface AccountEntry {
username: string; username: string;
/** Authentication mode */ /** Authentication mode */
authMode: 'basic' | 'oauth'; authMode: 'basic' | 'oauth';
/** Cookie slot index (04) for session/token cookies */ /** Cookie slot index for session/token cookies (0 ≤ slot < MAX_ACCOUNT_SLOTS) */
cookieSlot: number; cookieSlot: number;
/** Whether "Remember Me" was checked (basic auth only) */ /** Whether "Remember Me" was checked (basic auth only) */
rememberMe: boolean; rememberMe: boolean;
@@ -80,8 +80,9 @@ export const useAccountStore = create<AccountState>()(
return id; return id;
} }
if (state.accounts.length >= MAX_ACCOUNTS) { const max = getMaxAccounts();
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`); if (state.accounts.length >= max) {
throw new Error(`Maximum of ${max} accounts reached`);
} }
const cookieSlot = state.getNextCookieSlot(); const cookieSlot = state.getNextCookieSlot();
@@ -178,10 +179,9 @@ export const useAccountStore = create<AccountState>()(
getNextCookieSlot: () => { getNextCookieSlot: () => {
const used = new Set(get().accounts.map((a) => a.cookieSlot)); const used = new Set(get().accounts.map((a) => a.cookieSlot));
for (let i = 0; i < MAX_ACCOUNTS; i++) { let i = 0;
if (!used.has(i)) return i; while (used.has(i)) i++;
} return i;
return 0; // fallback, shouldn't happen if max is enforced
}, },
hasAccount: (username, serverUrl) => { hasAccount: (username, serverUrl) => {