fix: gate admin routes against cross-origin CSRF

This commit is contained in:
Linus Rath
2026-05-18 13:21:01 +02:00
parent b299a0b602
commit c2eb2c081b
16 changed files with 82 additions and 39 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
Binary file not shown.
+2 -2
View File
@@ -44,7 +44,7 @@ function sanitizeFilename(name: string): string {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -114,7 +114,7 @@ export async function POST(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+1 -1
View File
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+4 -4
View File
@@ -20,9 +20,9 @@ const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
await configManager.ensureLoaded();
@@ -54,7 +54,7 @@ export async function GET() {
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -109,7 +109,7 @@ export async function PATCH(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+2 -2
View File
@@ -19,11 +19,11 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
* Lets admins audit what they're about to install before pressing the button.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { slug } = await params;
+2 -2
View File
@@ -28,7 +28,7 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { searchParams } = request.nextUrl;
@@ -93,7 +93,7 @@ export async function GET(request: NextRequest) {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+1 -1
View File
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
+4 -4
View File
@@ -19,9 +19,9 @@ function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const entries = await listApprovals();
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
@@ -33,7 +33,7 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
@@ -62,7 +62,7 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
+3 -3
View File
@@ -34,7 +34,7 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const adminAuth = await requireAdminAuth();
const adminAuth = await requireAdminAuth(request);
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
@@ -85,7 +85,7 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { id } = await params;
@@ -139,7 +139,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { id } = await params;
+5 -5
View File
@@ -32,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
/**
* GET /api/admin/plugins - List all admin-managed plugins
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const [registry, devEntries] = await Promise.all([
@@ -64,7 +64,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -222,7 +222,7 @@ export async function POST(request: NextRequest) {
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -264,7 +264,7 @@ export async function PATCH(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+1 -1
View File
@@ -26,7 +26,7 @@ export async function GET() {
*/
export async function PUT(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+3 -3
View File
@@ -19,9 +19,9 @@ import {
* Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain.
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
@@ -61,7 +61,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
+5 -5
View File
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/**
* GET /api/admin/themes - List all admin-managed themes
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const registry = await getThemeRegistry();
@@ -36,7 +36,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+3 -3
View File
@@ -13,9 +13,9 @@ import {
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const state = await loadState();
@@ -47,7 +47,7 @@ export async function GET() {
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(req);
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
+45 -2
View File
@@ -81,9 +81,52 @@ export function verifyAdminSession(token: string): AdminSessionPayload | null {
}
/**
* Validate the admin session from cookies. Returns the payload or a 401 response.
* CSRF gate for cookie-authed admin requests.
*
* The admin session cookie is `SameSite=Lax`, which still allows top-level
* cross-site POST navigations (e.g. a form auto-submitted by an attacker
* page the admin is tricked into visiting). Without a CSRF check, any such
* page can trigger arbitrary state changes carrying the admin cookie.
*
* Strategy: state-changing requests must come from the same origin. Modern
* browsers (since 2020) always send `Sec-Fetch-Site` and that header
* cannot be set by JS, so it is the authoritative signal. Older browsers
* fall back to `Origin`. Non-browser clients (curl, scripts) send neither
* header and cannot ride a victim's cookie cross-origin, so the absence
* of both headers is allowed.
*/
export async function requireAdminAuth(): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
export function isSameOriginRequest(request: Request): boolean {
const method = request.method.toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return true;
const fetchSite = request.headers.get('sec-fetch-site');
if (fetchSite !== null) {
return fetchSite === 'same-origin';
}
const origin = request.headers.get('origin');
if (!origin) return true;
try {
const originHost = new URL(origin).host;
const requestHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
return !!requestHost && originHost === requestHost;
} catch {
return false;
}
}
/**
* Validate the admin session from cookies. Returns the payload or a 401 response.
*
* Also rejects cross-origin state-changing requests with 403 to prevent CSRF
* against cookie-authenticated admin actions.
*/
export async function requireAdminAuth(request: Request): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
if (!isSameOriginRequest(request)) {
return { error: NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 }) };
}
const cookieStore = await cookies();
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;