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
+11 -13
View File
@@ -4,12 +4,7 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import {
JmapAuthVerificationError,
normalizeJmapServerUrl,
validateProxyAuthHeader,
verifyJmapAuth,
} from '@/lib/auth/verify-jmap-auth';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
@@ -86,13 +81,16 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
// Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie
// we write here is only ever consumed for requests on behalf of this same
// user, so bogus credentials would just yield 401s downstream rather than
// privilege escalation. Untrusted custom endpoints still verify upstream.
const normalizedServerUrl = upstreamTrusted
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
// Always verify the credential upstream (GHSA-wxcm-j4jc-9fxq). The cookies
// written here are not only replayed as credentials (where a bogus
// password would just 401 downstream): the encrypted auth context is also
// accepted as proof of identity by routes that never contact the mail
// server, such as settings sync. Skipping the check for admin-configured
// servers let anyone mint a cookie for any username with a made-up
// password. `trusted` only relaxes the public-address requirement.
const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, {
trusted: upstreamTrusted,
});
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, sessionCookieOptions());
+12 -17
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
@@ -62,22 +62,17 @@ export async function POST(request: NextRequest) {
}
const slot = getSlot(request, bodySlot);
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
// still bind the cookie's `username` to the credential when we can verify
// locally. Without this, a caller can POST username="admin@host" +
// authHeader=<their own Basic creds>, and downstream consumers that read
// the cookie-derived username (audit logs, login tracker) accept the
// spoof. Bearer tokens are opaque so only the format check runs;
// authorization sinks must key off the credential itself, not the
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
let normalizedServerUrl: string;
if (upstreamTrusted) {
validateProxyAuthHeader(authHeader);
assertBasicAuthMatchesUsername(authHeader, username);
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
} else {
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
}
// Always verify the credential upstream and bind the cookie's `username`
// to it (GHSA-wxcm-j4jc-9fxq). The encrypted context this route writes is
// accepted as proof of identity by routes that never contact the mail
// server (settings sync, plugin-approval attribution), so minting it for
// an unchecked username lets anyone without an account read and overwrite
// any user's synced settings. Admin-configured servers are `trusted`,
// which only relaxes the public-address requirement (they may live on a
// private network) - never the credential check.
const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, {
trusted: upstreamTrusted,
});
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
@@ -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 });
});
});
+200 -3
View File
@@ -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;