fix: patch critical auth bypass and credential leak vulnerabilities

This commit is contained in:
Linus Rath
2026-03-31 00:46:15 +02:00
parent 1cce5c3c8a
commit 5da0e2bdf1
4 changed files with 51 additions and 10 deletions
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
/**
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
@@ -44,6 +45,9 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
@@ -88,6 +92,9 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
+39
View File
@@ -59,6 +59,45 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
{ serverUrl, username },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT — retrieve full credentials (including password) for session restoration.
* Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
*/
export async function PUT(request: NextRequest) {
try {
// Block non-browser and cross-origin requests
const secFetchSite = request.headers.get('sec-fetch-site');
if (secFetchSite !== 'same-origin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const slot = getSlot(request);
const cookieName = sessionCookieName(slot);
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
}
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
+2 -7
View File
@@ -53,16 +53,14 @@ function isEnabled(): boolean {
/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4).
* Returns true if any slot matches OR if no session cookies exist at all.
* Returns true only if a matching session cookie is found.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
let hasAnyCookie = false;
for (let slot = 0; slot <= 4; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (!token) continue;
hasAnyCookie = true;
const session = decryptSession(token);
if (session && session.username === username && session.serverUrl === serverUrl) {
@@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
}
}
// No cookies at all → can't verify, allow (same-origin protection applies)
if (!hasAnyCookie) return true;
// Cookies exist but none matched → identity mismatch
// No matching session found (or no cookies at all)reject
return false;
}
+3 -3
View File
@@ -1014,7 +1014,7 @@ export const useAuthStore = create<AuthState>()(
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
}
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`);
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { serverUrl, username, password } = await res.json();
targetClient = new JMAPClient(serverUrl, username, password);
@@ -1179,7 +1179,7 @@ export const useAuthStore = create<AuthState>()(
throw new Error(`Token refresh failed: ${res.status}`);
}
} else if (account.authMode === 'basic' && account.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`);
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { serverUrl, username, password } = await res.json();
const client = new JMAPClient(serverUrl, username, password);
@@ -1370,7 +1370,7 @@ export const useAuthStore = create<AuthState>()(
if (state.authMode === 'basic') {
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
try {
const res = await fetch('/api/auth/session');
const res = await fetch('/api/auth/session', { method: 'PUT' });
if (res.ok) {
const data = await res.json();
if (!data.serverUrl || !data.username || !data.password) {