diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts index 7e68903b..980a5904 100644 --- a/app/api/admin/audit/route.ts +++ b/app/api/admin/audit/route.ts @@ -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)); diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 1720059b..949934dd 100644 Binary files a/app/api/admin/auth/route.ts and b/app/api/admin/auth/route.ts differ diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 9057bdc1..9d0e8242 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -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); diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts index 30b8b33e..e29cc542 100644 --- a/app/api/admin/change-password/route.ts +++ b/app/api/admin/change-password/route.ts @@ -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); diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index b3d1ed1a..7bc89aaa 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -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); diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index ead554ae..ddd003da 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -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; diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 5251d812..b3720bad 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -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); diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts index 0182e197..81aa6cf1 100644 --- a/app/api/admin/oauth/setup/route.ts +++ b/app/api/admin/oauth/setup/route.ts @@ -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); diff --git a/app/api/admin/plugin-approvals/route.ts b/app/api/admin/plugin-approvals/route.ts index 5de7f2f9..200f3ad7 100644 --- a/app/api/admin/plugin-approvals/route.ts +++ b/app/api/admin/plugin-approvals/route.ts @@ -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. diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index d17c494e..e8c5e9a0 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -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; diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index e70bae49..78c7e8b6 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -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); diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 4d2f0529..79b68bd2 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -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); diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index a08e7656..192e64b3 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -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); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index d585ca89..74bb9ea1 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -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); diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts index bbeb4b26..48655c04 100644 --- a/app/api/admin/version/route.ts +++ b/app/api/admin/version/route.ts @@ -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; diff --git a/lib/admin/session.ts b/lib/admin/session.ts index fb08468f..4f1a73d5 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -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;