fix: key synced settings on the account a token login resolves to, not on a bare claimed name

This commit is contained in:
Linus Rath
2026-10-19 00:45:11 +02:00
parent 89ef4d14f2
commit f91b214719
4 changed files with 63 additions and 10 deletions
+9 -3
View File
@@ -4,7 +4,7 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession, sessionAuthHeader, type SessionCredentials } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
@@ -144,12 +144,17 @@ export async function POST(request: NextRequest) {
{ status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } },
);
}
// The context cookie also records which account the credential belongs
// to: a Bearer login may claim a bare local part (`john`) that several
// accounts on a multi-domain server answer to, and per-user data such as
// synced settings must be keyed on the account, not on the claim.
let normalizedServerUrl: string;
let accountName: string;
try {
normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, {
({ serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, {
trusted: upstreamTrusted,
clientIp,
});
}));
} catch (error) {
// Only a credential the server rejected spends the budget; an outage
// or a mismatched claim says nothing about the password.
@@ -164,6 +169,7 @@ export async function POST(request: NextRequest) {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
});
void recordLogin(username, normalizedServerUrl);
+12 -7
View File
@@ -79,20 +79,25 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<stri
const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie
// The context cookie first: it is the one that knows the account name,
// and every sign-in path that writes the session cookie writes it too.
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return ctx.accountName ?? username;
}
// Remember-me session cookie without a context (older sign-ins). A
// password login proves exactly the claimed name; a token login may have
// claimed a bare local part that only the context could resolve, so it
// does not key anything on its own.
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
if ('token' in session && session.token && !username.includes('@')) continue;
return username;
}
}
// Check stalwart auth context cookie (set for all auth modes)
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return ctx.accountName ?? username;
}
}
// No matching session found (or no cookies at all) → reject
@@ -230,6 +230,14 @@ describe('POST /api/auth/session (GHSA-wxcm-j4jc-9fxq)', () => {
expect.anything(), 0, expect.objectContaining({ username: 'alice@example.org', serverUrl: SERVER }),
);
});
it('records the account a bare Bearer claim resolves to, so per-user data is not keyed on the claim', async () => {
const { status } = await postSession({ serverUrl: SERVER, username: 'mallory', token: 'tok-mallory', slot: 0 });
expect(status).toBe(200);
expect(setStalwartAuthContextInStore).toHaveBeenCalledWith(
expect.anything(), 0, expect.objectContaining({ username: 'mallory', accountName: 'mallory@example.org' }),
);
});
});
describe('POST /api/auth/stalwart-context (GHSA-wxcm-j4jc-9fxq)', () => {
@@ -84,6 +84,40 @@ describe('settings route keys on the account the credential belongs to', () => {
expect(await res.json()).toEqual({ settings: null });
});
it('keys on the canonical account even when a remember-me session cookie carries the bare claim', async () => {
// /api/auth/session writes both cookies for a token login; the session
// cookie only knows the claim, the context the account it resolved to.
store.set(`john|${SERVER}`, { signature: 'john@a.example private' });
await signIn({ username: 'john', accountName: 'john@b.example' });
const { encryptSession } = await import('@/lib/auth/crypto');
const { sessionCookieName } = await import('@/lib/auth/session-cookie');
jar.set(sessionCookieName(0), encryptSession(SERVER, 'john', { token: 't' }));
const { GET, POST } = await import('@/app/api/settings/route');
const read = await GET(new NextRequest('https://webmail.example/api/settings', {
headers: { 'x-settings-username': 'john', 'x-settings-server': SERVER },
}));
expect(await read.json()).toEqual({ settings: null });
await POST(new NextRequest('https://webmail.example/api/settings', {
method: 'POST',
headers: SAME_ORIGIN,
body: JSON.stringify({ username: 'john', serverUrl: SERVER, settings: { theme: 'dark' } }),
}));
expect(store.get(`john|${SERVER}`)).toEqual({ signature: 'john@a.example private' });
expect(store.get(`john@b.example|${SERVER}`)).toEqual({ theme: 'dark' });
});
it('refuses to key on a bare token-login claim when no context cookie resolves it', async () => {
jar.clear();
const { encryptSession } = await import('@/lib/auth/crypto');
const { sessionCookieName } = await import('@/lib/auth/session-cookie');
jar.set(sessionCookieName(0), encryptSession(SERVER, 'john', { token: 't' }));
const { GET } = await import('@/app/api/settings/route');
const res = await GET(new NextRequest('https://webmail.example/api/settings', {
headers: { 'x-settings-username': 'john', 'x-settings-server': SERVER },
}));
expect(res.status).toBe(403);
});
it('keeps keying on the login name when the context names no other account', async () => {
await signIn({ username: 'alice@example.org' });
const { POST } = await import('@/app/api/settings/route');