From f275fbe2e4c61f40a7070d18602f45e7edfd0481 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 13:00:40 +0200 Subject: [PATCH] fix: bind stalwart auth context to credential, not cookie-claimed username --- app/api/admin/auth/route.ts | Bin 9246 -> 9729 bytes app/api/auth/stalwart-context/route.ts | 27 ++++++++++++++++--------- lib/auth/verify-jmap-auth.ts | 25 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 2dd882abb482f4857ec338ed96a7e2a2c32f6272..1720059bba70894b3337f351666a1a07bb63eea9 100644 GIT binary patch delta 501 zcmYL`F-`+95JelBAaMYwzS3+WkzENvnkW(qO(ZC}z{H+ptvL2#dlj}o5jQ}hr{M&M z>u?xmHwkpsc;?Oje?I1~4{vwpDljFUQ!Md5BO3TRx kZC}j)5yM=Y6~{El78H4WT?d;DC-0xV;-tlW|MX@Tzxv3m_y7O^ delta 30 mcmZqlnddRVd1Gb@=i~|8+FZqYrNya5d5O8HoA+}&NdW+~h6_so diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 24426454..d4f06d51 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; -import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } 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'; @@ -57,15 +57,22 @@ export async function POST(request: NextRequest) { } const slot = getSlot(request, bodySlot); - // Trusted (admin-configured) URLs skip the upstream re-fetch: the caller - // just authenticated to JMAP with these credentials, and the cookie we - // write here is only ever consumed for requests on behalf of this same - // user - a bogus auth header would just yield 401s downstream, not - // privilege escalation. For untrusted custom endpoints we still verify - // upstream as before. - const normalizedServerUrl = upstreamTrusted - ? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl)) - : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); + // 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=, 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 }); + } await setStalwartAuthContext(slot, { serverUrl: normalizedServerUrl, diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 1d2d1616..06a55708 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -40,6 +40,31 @@ export function validateProxyAuthHeader(authHeader: string): void { } } +/** + * For a `Basic` Authorization header, assert that the user portion of the + * credentials matches `claimedUsername`. Prevents callers of routes that + * accept independent `username` + `authHeader` fields from binding a cookie + * to one identity while authenticating as another. No-op for Bearer. + */ +export function assertBasicAuthMatchesUsername(authHeader: string, claimedUsername: string): void { + const match = /^Basic\s+(\S+)$/i.exec(authHeader); + if (!match) return; + let decoded: string; + try { + decoded = Buffer.from(match[1], 'base64').toString('utf8'); + } catch { + throw new JmapAuthVerificationError('Invalid Authorization header', 400); + } + const colon = decoded.indexOf(':'); + if (colon < 0) { + throw new JmapAuthVerificationError('Invalid Authorization header', 400); + } + const credUser = decoded.slice(0, colon); + if (credUser !== claimedUsername) { + throw new JmapAuthVerificationError('Username does not match credentials', 400); + } +} + export async function verifyJmapAuth( serverUrl: string, authHeader: string,