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:
@@ -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