mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: verify credentials upstream before minting identity cookies (GHSA-wxcm-j4jc-9fxq)
This commit is contained in:
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -87,11 +87,196 @@ export function assertBasicAuthMatchesUsername(authHeader: string, claimedUserna
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifiedJmapSession {
|
||||
apiUrl: string;
|
||||
/** Canonical login name the server reports for the credential (RFC 8620 §2). */
|
||||
username?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
accounts: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VerifyJmapAuthOptions {
|
||||
trusted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify `authHeader` against `serverUrl` and return the normalized URL.
|
||||
* Callers that need to bind a username claim to the credential must use
|
||||
* {@link verifyJmapIdentity} instead: a valid credential alone says nothing
|
||||
* about *which* user it belongs to.
|
||||
*/
|
||||
export async function verifyJmapAuth(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
options: { trusted?: boolean } = {},
|
||||
options: VerifyJmapAuthOptions = {},
|
||||
): Promise<string> {
|
||||
const { serverUrl: normalizedServerUrl } = await fetchVerifiedSession(serverUrl, authHeader, options);
|
||||
return normalizedServerUrl;
|
||||
}
|
||||
|
||||
function usernamesEqual(a: string, b: string): boolean {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the claimed login name denote the server-reported one? Stalwart
|
||||
* canonicalizes the login it echoes in `Session.username` (`UserA@x` and the
|
||||
* bare local part `usera` both come back as `usera@x`), so besides a
|
||||
* case-insensitive match we accept a bare local part on either side.
|
||||
*/
|
||||
export function usernameMatchesSession(claimed: string, sessionUsername: string | undefined): boolean {
|
||||
if (!sessionUsername) return false;
|
||||
if (usernamesEqual(claimed, sessionUsername)) return true;
|
||||
const claimedAt = claimed.indexOf('@');
|
||||
const sessionAt = sessionUsername.indexOf('@');
|
||||
if (claimedAt < 0 && sessionAt > 0) {
|
||||
return usernamesEqual(claimed, sessionUsername.slice(0, sessionAt));
|
||||
}
|
||||
if (sessionAt < 0 && claimedAt > 0) {
|
||||
return usernamesEqual(claimed.slice(0, claimedAt), sessionUsername);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify `authHeader` upstream AND prove that `claimedUsername` is the user
|
||||
* that credential authenticates (GHSA-wxcm-j4jc-9fxq). The identity cookies
|
||||
* minted from this result are consumed by routes that trust the cookie's
|
||||
* username claim without contacting the mail server (settings sync, plugin
|
||||
* approval attribution), so an unbound claim is a cross-user forgery.
|
||||
*
|
||||
* - Basic: the credential's user part must equal the claim; the upstream
|
||||
* session fetch then proves that user's password is right.
|
||||
* - Bearer: the token is opaque, so the claim is checked against the
|
||||
* server's `Session.username`, and failing that against the e-mail
|
||||
* addresses of the account's sending identities (OAuth/SSO logins register
|
||||
* under the primary identity's e-mail, which may differ from the login).
|
||||
*
|
||||
* Returns the normalized server URL.
|
||||
*/
|
||||
export async function verifyJmapIdentity(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
claimedUsername: string,
|
||||
options: VerifyJmapAuthOptions = {},
|
||||
): Promise<string> {
|
||||
if (!claimedUsername) {
|
||||
throw new JmapAuthVerificationError('Missing username', 400);
|
||||
}
|
||||
validateProxyAuthHeader(authHeader);
|
||||
const isBasic = /^Basic\s/i.test(authHeader);
|
||||
if (isBasic) {
|
||||
assertBasicAuthMatchesUsername(authHeader, claimedUsername);
|
||||
}
|
||||
|
||||
const { serverUrl: normalizedServerUrl, session } = await fetchVerifiedSession(serverUrl, authHeader, options);
|
||||
if (isBasic) {
|
||||
return normalizedServerUrl;
|
||||
}
|
||||
|
||||
if (usernameMatchesSession(claimedUsername, session.username)) {
|
||||
return normalizedServerUrl;
|
||||
}
|
||||
|
||||
const identityEmails = await fetchIdentityEmails(normalizedServerUrl, authHeader, session, options);
|
||||
if (identityEmails.some((email) => usernamesEqual(email, claimedUsername))) {
|
||||
return normalizedServerUrl;
|
||||
}
|
||||
|
||||
throw new JmapAuthVerificationError('Username does not match credentials', 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebase the session's advertised `apiUrl` onto the origin we verified
|
||||
* against: the advertised public hostname may not resolve from this process
|
||||
* (mirrors `rebaseApiUrl` in `@/lib/stalwart/jmap-api`).
|
||||
*/
|
||||
function rebaseApiUrl(apiUrl: string, normalizedServerUrl: string): string {
|
||||
const base = new URL(normalizedServerUrl);
|
||||
const api = new URL(apiUrl, `${normalizedServerUrl}/`);
|
||||
return new URL(api.pathname + api.search, base.origin).toString();
|
||||
}
|
||||
|
||||
async function fetchIdentityEmails(
|
||||
normalizedServerUrl: string,
|
||||
authHeader: string,
|
||||
session: VerifiedJmapSession,
|
||||
options: VerifyJmapAuthOptions,
|
||||
): Promise<string[]> {
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (typeof accountId !== 'string' || !accountId) return [];
|
||||
|
||||
let apiUrl: string;
|
||||
try {
|
||||
apiUrl = rebaseApiUrl(session.apiUrl, normalizedServerUrl);
|
||||
} catch {
|
||||
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||
try {
|
||||
if (!options.trusted && !(await isPublicHttpUrl(apiUrl))) {
|
||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||
}
|
||||
const requestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [['Identity/get', { accountId, ids: null }, '0']],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
};
|
||||
// No redirect handling here: a redirect on the POST would downgrade it to
|
||||
// a GET and can only come from a misconfigured proxy - treat as failure.
|
||||
const response = options.trusted
|
||||
? await fetch(apiUrl, { ...requestInit, redirect: 'manual' })
|
||||
: await fetchPublicUrl(apiUrl, requestInit);
|
||||
if (!response.ok) {
|
||||
throw new JmapAuthVerificationError(
|
||||
response.status === 401 || response.status === 403
|
||||
? 'Authentication failed'
|
||||
: 'Failed to verify JMAP identity',
|
||||
response.status === 401 || response.status === 403 ? 401 : 502,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const body = await response.json().catch(() => null) as { methodResponses?: unknown } | null;
|
||||
const responses = Array.isArray(body?.methodResponses) ? body.methodResponses : null;
|
||||
if (!responses) {
|
||||
throw new JmapAuthVerificationError('Invalid JMAP response', 502);
|
||||
}
|
||||
const emails: string[] = [];
|
||||
for (const entry of responses) {
|
||||
if (!Array.isArray(entry) || entry[0] !== 'Identity/get') continue;
|
||||
const list = (entry[1] as { list?: unknown })?.list;
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const identity of list) {
|
||||
const email = (identity as { email?: unknown })?.email;
|
||||
if (typeof email === 'string' && email) emails.push(email);
|
||||
}
|
||||
}
|
||||
return emails;
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) throw error;
|
||||
if (error instanceof DisallowedUrlError) {
|
||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new JmapAuthVerificationError('JMAP identity verification timed out', 504);
|
||||
}
|
||||
throw new JmapAuthVerificationError('Failed to verify JMAP identity', 502);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVerifiedSession(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
options: VerifyJmapAuthOptions,
|
||||
): Promise<{ serverUrl: string; session: VerifiedJmapSession }> {
|
||||
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||
validateProxyAuthHeader(authHeader);
|
||||
|
||||
@@ -152,12 +337,24 @@ export async function verifyJmapAuth(
|
||||
);
|
||||
}
|
||||
|
||||
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
|
||||
const session = await response.json().catch(() => null) as
|
||||
{ apiUrl?: unknown; accounts?: unknown; username?: unknown; primaryAccounts?: unknown } | null;
|
||||
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
|
||||
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
|
||||
}
|
||||
|
||||
return normalizedServerUrl;
|
||||
return {
|
||||
serverUrl: normalizedServerUrl,
|
||||
session: {
|
||||
apiUrl: session.apiUrl,
|
||||
username: typeof session.username === 'string' ? session.username : undefined,
|
||||
primaryAccounts:
|
||||
session.primaryAccounts && typeof session.primaryAccounts === 'object'
|
||||
? (session.primaryAccounts as Record<string, string>)
|
||||
: undefined,
|
||||
accounts: session.accounts as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user