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,