fix: patch critical auth bypass and credential leak vulnerabilities
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user