diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 2dd882ab..1720059b 100644 Binary files a/app/api/admin/auth/route.ts and b/app/api/admin/auth/route.ts differ 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,