mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
fix: key synced settings on the account a token login resolves to, not on a bare claimed name
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user