fix: bind stalwart auth context to credential, not cookie-claimed username
This commit is contained in:
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
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 { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
@@ -57,15 +57,22 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slot = getSlot(request, bodySlot);
|
const slot = getSlot(request, bodySlot);
|
||||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
|
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
|
||||||
// just authenticated to JMAP with these credentials, and the cookie we
|
// still bind the cookie's `username` to the credential when we can verify
|
||||||
// write here is only ever consumed for requests on behalf of this same
|
// locally. Without this, a caller can POST username="admin@host" +
|
||||||
// user - a bogus auth header would just yield 401s downstream, not
|
// authHeader=<their own Basic creds>, and downstream consumers that read
|
||||||
// privilege escalation. For untrusted custom endpoints we still verify
|
// the cookie-derived username (audit logs, login tracker) accept the
|
||||||
// upstream as before.
|
// spoof. Bearer tokens are opaque so only the format check runs;
|
||||||
const normalizedServerUrl = upstreamTrusted
|
// authorization sinks must key off the credential itself, not the
|
||||||
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
|
||||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
let normalizedServerUrl: string;
|
||||||
|
if (upstreamTrusted) {
|
||||||
|
validateProxyAuthHeader(authHeader);
|
||||||
|
assertBasicAuthMatchesUsername(authHeader, username);
|
||||||
|
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
|
||||||
|
} else {
|
||||||
|
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||||
|
}
|
||||||
|
|
||||||
await setStalwartAuthContext(slot, {
|
await setStalwartAuthContext(slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
|
|||||||
@@ -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(
|
export async function verifyJmapAuth(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
authHeader: string,
|
authHeader: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user