fix: verify credentials upstream before minting identity cookies (GHSA-wxcm-j4jc-9fxq)

This commit is contained in:
Linus Rath
2026-09-17 20:48:42 +02:00
parent d5c0996519
commit e055ee8201
6 changed files with 727 additions and 34 deletions
@@ -0,0 +1,308 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* GHSA-wxcm-j4jc-9fxq: /api/auth/session and /api/auth/stalwart-context used
* to mint the encrypted identity cookies for any username when the requested
* server was the admin-configured one, without checking the credential
* upstream. Routes that trust the cookie's username claim without contacting
* the mail server (settings sync, plugin-approval attribution) then accepted
* the forged identity. Both routes must now verify upstream and bind the
* username to the credential before writing anything.
*/
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({
json: async () => data,
status: init?.status ?? 200,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
const cookieSet = vi.fn();
vi.mock('next/headers', () => ({
cookies: async () => ({
get: () => undefined,
set: cookieSet,
delete: vi.fn(),
getAll: () => [],
}),
}));
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
const encryptSession = vi.fn((..._args: unknown[]) => 'encrypted-session');
vi.mock('@/lib/auth/crypto', () => ({
encryptSession: (...args: unknown[]) => encryptSession(...args),
decryptSession: () => null,
decryptPayload: () => null,
encryptPayload: () => 'encrypted',
}));
const setStalwartAuthContext = vi.fn();
const setStalwartAuthContextInStore = vi.fn();
vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args),
setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args),
clearStalwartAuthContextInStore: vi.fn(),
}));
const recordLogin = vi.fn();
vi.mock('@/lib/telemetry/login-tracker', () => ({
recordLogin: (...args: unknown[]) => recordLogin(...args),
}));
const SERVER = 'https://mail.example.org';
const HOST = 'webmail.example';
/** The mail server as the routes see it: who exists, and which token is whose. */
const PASSWORDS: Record<string, string> = {
'alice@example.org': 'alice-secret',
'mallory@example.org': 'mallory-secret',
// OAuth/SSO login name that differs from the primary identity e-mail.
'lrath': 'linus-secret',
};
const TOKENS: Record<string, string> = {
'tok-mallory': 'mallory@example.org',
'tok-linus': 'lrath',
};
const IDENTITY_EMAILS: Record<string, string[]> = {
'mallory@example.org': ['mallory@example.org'],
'lrath': ['linus@example.org'],
};
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
}
function authenticate(init?: RequestInit): string | null {
const auth = new Headers(init?.headers).get('authorization') || '';
const basic = /^Basic\s+(\S+)$/i.exec(auth);
if (basic) {
const [user, pass] = Buffer.from(basic[1], 'base64').toString('utf8').split(':');
return PASSWORDS[user] === pass ? user : null;
}
const bearer = /^Bearer\s+(\S+)$/i.exec(auth);
if (bearer) return TOKENS[bearer[1]] ?? null;
return null;
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
let identityGetCalls: number;
function installFakeServer() {
identityGetCalls = 0;
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input);
const user = authenticate(init);
if (!user) {
return json({ type: 'about:blank', status: 401, title: 'Unauthorized' }, 401);
}
if (url === `${SERVER}/.well-known/jmap`) {
return json({
// Advertised on a hostname this process cannot resolve, like Stalwart does.
apiUrl: 'https://stwtest.local/jmap/',
username: user,
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' },
accounts: { c: { name: user } },
});
}
if (url === `${SERVER}/jmap/` && init?.method === 'POST') {
identityGetCalls++;
const list = (IDENTITY_EMAILS[user] ?? []).map((email, i) => ({ id: `i${i}`, email, name: user }));
return json({ methodResponses: [['Identity/get', { accountId: 'c', list, notFound: [] }, '0']] });
}
return json({ error: `unexpected ${init?.method ?? 'GET'} ${url}` }, 404);
});
}
type HeaderMap = Record<string, string>;
const SAME_ORIGIN_FETCH: HeaderMap = {
host: HOST,
origin: `https://${HOST}`,
'content-type': 'application/json',
'sec-fetch-site': 'same-origin',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'empty',
};
function mockRequest(body: unknown) {
return {
method: 'POST',
json: async () => body,
headers: { get: (k: string) => SAME_ORIGIN_FETCH[k.toLowerCase()] ?? null },
nextUrl: { searchParams: new URLSearchParams(), origin: `https://${HOST}` },
};
}
type RouteResponse = { status: number; json: () => Promise<{ error?: string; ok?: boolean }> };
async function postSession(body: unknown) {
const { POST } = await import('@/app/api/auth/session/route');
const res = (await POST(mockRequest(body) as unknown as Parameters<typeof POST>[0])) as unknown as RouteResponse;
return { status: res.status, body: await res.json() };
}
async function postContext(body: unknown) {
const { POST } = await import('@/app/api/auth/stalwart-context/route');
const res = (await POST(mockRequest(body) as unknown as Parameters<typeof POST>[0])) as unknown as RouteResponse;
return { status: res.status, body: await res.json() };
}
function basic(user: string, pass: string): string {
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
}
beforeEach(() => {
for (const k of Object.keys(config)) delete config[k];
config.jmapServerUrl = SERVER;
cookieSet.mockClear();
encryptSession.mockClear();
setStalwartAuthContext.mockClear();
setStalwartAuthContextInStore.mockClear();
recordLogin.mockClear();
vi.resetModules();
installFakeServer();
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('POST /api/auth/session (GHSA-wxcm-j4jc-9fxq)', () => {
it('refuses to mint cookies for a wrong password on the configured server', async () => {
const { status, body } = await postSession({
serverUrl: SERVER, username: 'alice@example.org', password: 'TOTALLY-WRONG', slot: 0,
});
expect(status).toBe(401);
expect(body.error).toMatch(/authentication failed/i);
expect(cookieSet).not.toHaveBeenCalled();
expect(setStalwartAuthContextInStore).not.toHaveBeenCalled();
expect(recordLogin).not.toHaveBeenCalled();
});
it('refuses to mint cookies for a user that does not exist upstream', async () => {
const { status } = await postSession({
serverUrl: SERVER, username: 'nobody@example.org', password: 'x', slot: 0,
});
expect(status).toBe(401);
expect(cookieSet).not.toHaveBeenCalled();
expect(setStalwartAuthContextInStore).not.toHaveBeenCalled();
});
it('writes no cookie when the upstream server cannot be reached', async () => {
fetchSpy.mockRejectedValue(new TypeError('fetch failed'));
const { status } = await postSession({
serverUrl: SERVER, username: 'alice@example.org', password: 'alice-secret', slot: 0,
});
expect(status).toBe(502);
expect(cookieSet).not.toHaveBeenCalled();
expect(setStalwartAuthContextInStore).not.toHaveBeenCalled();
});
it('still mints cookies for the right password after checking upstream', async () => {
const { status, body } = await postSession({
serverUrl: SERVER, username: 'alice@example.org', password: 'alice-secret', slot: 0,
});
expect(status).toBe(200);
expect(body.ok).toBe(true);
expect(fetchSpy).toHaveBeenCalledWith(
`${SERVER}/.well-known/jmap`,
expect.objectContaining({ headers: { Authorization: basic('alice@example.org', 'alice-secret') } }),
);
expect(cookieSet).toHaveBeenCalledTimes(1);
expect(setStalwartAuthContextInStore).toHaveBeenCalledWith(
expect.anything(), 0, expect.objectContaining({ username: 'alice@example.org', serverUrl: SERVER }),
);
});
});
describe('POST /api/auth/stalwart-context (GHSA-wxcm-j4jc-9fxq)', () => {
it('refuses a Basic credential the server rejects', async () => {
const { status } = await postContext({
serverUrl: SERVER, username: 'alice@example.org', authHeader: basic('alice@example.org', 'nope'), slot: 0,
});
expect(status).toBe(401);
expect(setStalwartAuthContext).not.toHaveBeenCalled();
});
it('refuses a Basic credential whose user part differs from the claimed username, before any fetch', async () => {
const { status, body } = await postContext({
serverUrl: SERVER, username: 'alice@example.org', authHeader: basic('mallory@example.org', 'mallory-secret'), slot: 0,
});
expect(status).toBe(400);
expect(body.error).toMatch(/does not match/i);
expect(fetchSpy).not.toHaveBeenCalled();
expect(setStalwartAuthContext).not.toHaveBeenCalled();
});
it('refuses a valid Bearer token whose owner is not the claimed username', async () => {
const { status, body } = await postContext({
serverUrl: SERVER, username: 'alice@example.org', authHeader: 'Bearer tok-mallory', slot: 0,
});
expect(status).toBe(403);
expect(body.error).toMatch(/does not match/i);
expect(identityGetCalls).toBe(1);
expect(setStalwartAuthContext).not.toHaveBeenCalled();
});
it('refuses a Bearer token when the identity lookup fails, rather than trusting the claim', async () => {
fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
if (String(input).endsWith('/.well-known/jmap')) {
return json({ apiUrl: `${SERVER}/jmap/`, username: 'lrath', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' }, accounts: {} });
}
return json({ error: 'boom' }, 500);
});
const { status } = await postContext({
serverUrl: SERVER, username: 'linus@example.org', authHeader: 'Bearer tok-linus', slot: 0,
});
expect(status).toBe(502);
expect(setStalwartAuthContext).not.toHaveBeenCalled();
});
it('accepts a Bearer token whose session username is the claim, without an identity lookup', async () => {
const { status } = await postContext({
serverUrl: SERVER, username: 'mallory@example.org', authHeader: 'Bearer tok-mallory', slot: 1,
});
expect(status).toBe(200);
expect(identityGetCalls).toBe(0);
expect(setStalwartAuthContext).toHaveBeenCalledWith(1, expect.objectContaining({ username: 'mallory@example.org' }));
});
it('accepts an OAuth login registered under the primary identity e-mail via Identity/get on the reachable host', async () => {
const { status } = await postContext({
serverUrl: SERVER, username: 'linus@example.org', authHeader: 'Bearer tok-linus', slot: 0,
});
expect(status).toBe(200);
expect(identityGetCalls).toBe(1);
// apiUrl was advertised on stwtest.local; the lookup must go to the verified origin.
expect(fetchSpy).toHaveBeenCalledWith(`${SERVER}/jmap/`, expect.objectContaining({ method: 'POST' }));
expect(setStalwartAuthContext).toHaveBeenCalledWith(0, expect.objectContaining({ username: 'linus@example.org' }));
});
it('accepts the bare local part of the session username (Stalwart canonicalizes it)', async () => {
const { status } = await postContext({
serverUrl: SERVER, username: 'lrath@corp.example', authHeader: 'Bearer tok-linus', slot: 0,
});
expect(status).toBe(200);
expect(identityGetCalls).toBe(0);
});
it('still mints the context for a Basic credential the server accepts', async () => {
const { status } = await postContext({
serverUrl: SERVER, username: 'alice@example.org', authHeader: basic('alice@example.org', 'alice-secret'), slot: 2,
});
expect(status).toBe(200);
expect(setStalwartAuthContext).toHaveBeenCalledWith(2, expect.objectContaining({ username: 'alice@example.org', serverUrl: SERVER }));
});
});
+15 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* GHSA-qvr9-m8cq-7wvg: the unauthenticated /api/auth/* routes write
@@ -150,12 +150,26 @@ describe('isSameOriginRequest', () => {
});
describe('POST /api/auth/session (GHSA-qvr9-m8cq-7wvg session fixation)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
for (const k of Object.keys(config)) delete config[k];
config.jmapServerUrl = TRUSTED;
cookieSet.mockClear();
setStalwartAuthContextInStore.mockClear();
vi.resetModules();
// The route now verifies the credential upstream even for the configured
// server (GHSA-wxcm-j4jc-9fxq); stand in for a server that accepts it.
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ apiUrl: `${TRUSTED}/jmap/`, username: SESSION_BODY.username, accounts: {} }),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('refuses a cross-site form POST before reading the body and writes no cookie', async () => {
+181
View File
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* verifyJmapIdentity binds a username claim to a verified credential
* (GHSA-wxcm-j4jc-9fxq). The route-level behaviour for admin-configured
* servers is covered in auth-routes-credential-check.test.ts; this file
* covers the matching rules and the user-chosen (untrusted) endpoint path,
* which must keep every hop on the rebinding-safe fetch.
*/
const lookup = vi.fn();
const guardedFetch = vi.fn();
vi.mock('@/lib/security/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/security/url-guard')>();
return {
...actual,
fetchPublicUrl: (...args: unknown[]) => guardedFetch(...args),
};
});
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
}
function basic(user: string, pass: string): string {
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
}
async function load() {
return import('@/lib/auth/verify-jmap-auth');
}
describe('usernameMatchesSession', () => {
it('matches exact and case-different login names', async () => {
const { usernameMatchesSession } = await load();
expect(usernameMatchesSession('usera@example.org', 'usera@example.org')).toBe(true);
expect(usernameMatchesSession('UserA@Example.org', 'usera@example.org')).toBe(true);
});
it('matches a bare local part against the canonical e-mail login on either side', async () => {
const { usernameMatchesSession } = await load();
expect(usernameMatchesSession('usera', 'usera@example.org')).toBe(true);
expect(usernameMatchesSession('linus@example.org', 'linus')).toBe(true);
});
it('rejects different users, different domains and missing session usernames', async () => {
const { usernameMatchesSession } = await load();
expect(usernameMatchesSession('alice@example.org', 'mallory@example.org')).toBe(false);
expect(usernameMatchesSession('alice@example.org', 'alice@other.example')).toBe(false);
expect(usernameMatchesSession('alice', 'alicia@example.org')).toBe(false);
expect(usernameMatchesSession('alice@example.org', undefined)).toBe(false);
expect(usernameMatchesSession('alice@example.org', '')).toBe(false);
});
});
describe('verifyJmapIdentity', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
lookup.mockReset();
guardedFetch.mockReset();
vi.resetModules();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('rejects an empty username before any fetch', async () => {
const { verifyJmapIdentity } = await load();
await expect(verifyJmapIdentity('https://example.com', 'Bearer x', '', { trusted: true }))
.rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects a Basic credential for another user before any fetch', async () => {
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://example.com', basic('mallory@example.org', 'pw'), 'alice@example.org', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('surfaces the upstream 401 for a Basic credential the server rejects', async () => {
fetchSpy.mockResolvedValueOnce(json({ status: 401 }, 401));
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://mail.internal', basic('alice@example.org', 'wrong'), 'alice@example.org', { trusted: true }),
).rejects.toMatchObject({ status: 401, upstreamStatus: 401 });
});
it('accepts a Basic credential the server accepts without inspecting Session.username', async () => {
// Stalwart canonicalizes the login; a Basic caller who typed an alias is
// still proven by the server accepting exactly that user:password pair.
fetchSpy.mockResolvedValueOnce(json({ apiUrl: 'https://mail.internal/jmap/', username: 'alice', accounts: {} }));
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://mail.internal/', basic('a.smith@example.org', 'pw'), 'a.smith@example.org', { trusted: true }),
).resolves.toBe('https://mail.internal');
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('on a user-chosen endpoint, runs the identity lookup through the guarded fetch on the verified origin', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
guardedFetch
.mockResolvedValueOnce(json({
apiUrl: 'https://public.example.com:8443/jmap/',
username: 'linus',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' },
accounts: {},
}))
.mockResolvedValueOnce(json({
methodResponses: [['Identity/get', { accountId: 'c', list: [{ id: 'i0', email: 'Linus.Rath@example.com' }] }, '0']],
}));
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://example.com', 'Bearer tok', 'linus.rath@example.com', { trusted: false }),
).resolves.toBe('https://example.com');
expect(guardedFetch).toHaveBeenCalledTimes(2);
expect(guardedFetch).toHaveBeenLastCalledWith(
'https://example.com/jmap/',
expect.objectContaining({ method: 'POST' }),
);
const body = JSON.parse((guardedFetch.mock.calls[1][1] as { body: string }).body);
expect(body.methodCalls[0][0]).toBe('Identity/get');
expect(body.methodCalls[0][1]).toEqual({ accountId: 'c', ids: null });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects a Bearer claim that neither Session.username nor any identity confirms', async () => {
fetchSpy
.mockResolvedValueOnce(json({
apiUrl: 'https://mail.internal/jmap/',
username: 'mallory@example.org',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' },
accounts: {},
}))
.mockResolvedValueOnce(json({
methodResponses: [['Identity/get', { accountId: 'c', list: [{ id: 'i0', email: 'mallory@example.org' }] }, '0']],
}));
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://mail.internal', 'Bearer tok', 'alice@example.org', { trusted: true }),
).rejects.toMatchObject({ status: 403 });
});
it('rejects a Bearer claim when the session has no primary mail account to look identities up on', async () => {
fetchSpy.mockResolvedValueOnce(json({ apiUrl: 'https://mail.internal/jmap/', username: 'mallory', accounts: {} }));
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://mail.internal', 'Bearer tok', 'alice@example.org', { trusted: true }),
).rejects.toMatchObject({ status: 403 });
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('treats a malformed Identity/get response as a failure, not as a match', async () => {
fetchSpy
.mockResolvedValueOnce(json({
apiUrl: 'https://mail.internal/jmap/',
username: 'mallory',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c' },
accounts: {},
}))
.mockResolvedValueOnce(new Response('not json', { status: 200 }));
const { verifyJmapIdentity } = await load();
await expect(
verifyJmapIdentity('https://mail.internal', 'Bearer tok', 'alice@example.org', { trusted: true }),
).rejects.toMatchObject({ status: 502 });
});
});