From 881992b6cc26fef5023ce1414bc08cff163659cd Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:02:13 +0200 Subject: [PATCH] Restrict analytics, GeoIP status, and OpenAPI spec to admin role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pentest found that all 8 analytics API endpoints, the GeoIP status endpoint, and the OpenAPI spec were accessible to any authenticated user. Since the user role should only have access to forward auth and self-service, these are now admin-only. - analytics/*: requireUser → requireAdmin - geoip-status: requireUser → requireAdmin - openapi.json: add requireApiAdmin + change Cache-Control to private - analytics/api-docs pages: requireUser → requireAdmin (defense-in-depth) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(dashboard)/analytics/page.tsx | 4 +-- app/(dashboard)/api-docs/page.tsx | 4 +-- app/api/analytics/blocked/route.ts | 4 +-- app/api/analytics/countries/route.ts | 4 +-- app/api/analytics/hosts/route.ts | 4 +-- app/api/analytics/protocols/route.ts | 4 +-- app/api/analytics/summary/route.ts | 4 +-- app/api/analytics/timeline/route.ts | 4 +-- app/api/analytics/user-agents/route.ts | 4 +-- app/api/analytics/waf-stats/route.ts | 4 +-- app/api/geoip-status/route.ts | 4 +-- app/api/v1/openapi.json/route.ts | 12 ++++++-- tests/unit/api-routes/openapi.test.ts | 40 +++++++++++++++++++++----- 13 files changed, 64 insertions(+), 32 deletions(-) diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx index 79b163c6..4afbd5dd 100644 --- a/app/(dashboard)/analytics/page.tsx +++ b/app/(dashboard)/analytics/page.tsx @@ -1,7 +1,7 @@ -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import AnalyticsClient from './AnalyticsClient'; export default async function AnalyticsPage() { - await requireUser(); + await requireAdmin(); return ; } diff --git a/app/(dashboard)/api-docs/page.tsx b/app/(dashboard)/api-docs/page.tsx index 4b536217..9eaa6646 100644 --- a/app/(dashboard)/api-docs/page.tsx +++ b/app/(dashboard)/api-docs/page.tsx @@ -1,4 +1,4 @@ -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; import ApiDocsClient from "./ApiDocsClient"; export const metadata = { @@ -6,7 +6,7 @@ export const metadata = { }; export default async function ApiDocsPage() { - await requireUser(); + await requireAdmin(); return ; } diff --git a/app/api/analytics/blocked/route.ts b/app/api/analytics/blocked/route.ts index c88b801d..27911502 100644 --- a/app/api/analytics/blocked/route.ts +++ b/app/api/analytics/blocked/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsBlocked, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { searchParams } = req.nextUrl; const hostsParam = searchParams.get('hosts') ?? ''; const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; diff --git a/app/api/analytics/countries/route.ts b/app/api/analytics/countries/route.ts index da6dea5e..28ccd3e6 100644 --- a/app/api/analytics/countries/route.ts +++ b/app/api/analytics/countries/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsCountries, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { searchParams } = req.nextUrl; const hostsParam = searchParams.get('hosts') ?? ''; const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; diff --git a/app/api/analytics/hosts/route.ts b/app/api/analytics/hosts/route.ts index 2cb3bd1e..38fa9815 100644 --- a/app/api/analytics/hosts/route.ts +++ b/app/api/analytics/hosts/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsHosts } from '@/src/lib/analytics-db'; export async function GET() { - await requireUser(); + await requireAdmin(); const hosts = await getAnalyticsHosts(); return NextResponse.json(hosts); } diff --git a/app/api/analytics/protocols/route.ts b/app/api/analytics/protocols/route.ts index ee48a05c..1cfa11af 100644 --- a/app/api/analytics/protocols/route.ts +++ b/app/api/analytics/protocols/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsProtocols, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { searchParams } = req.nextUrl; const hostsParam = searchParams.get('hosts') ?? ''; const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; diff --git a/app/api/analytics/summary/route.ts b/app/api/analytics/summary/route.ts index 0b66bf32..6fb643cc 100644 --- a/app/api/analytics/summary/route.ts +++ b/app/api/analytics/summary/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsSummary, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { searchParams } = req.nextUrl; const hostsParam = searchParams.get('hosts') ?? ''; const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; diff --git a/app/api/analytics/timeline/route.ts b/app/api/analytics/timeline/route.ts index 56b1a79a..d2fd058f 100644 --- a/app/api/analytics/timeline/route.ts +++ b/app/api/analytics/timeline/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsTimeline, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { searchParams } = req.nextUrl; const hostsParam = searchParams.get('hosts') ?? ''; const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; diff --git a/app/api/analytics/user-agents/route.ts b/app/api/analytics/user-agents/route.ts index 9470e907..86e6a055 100644 --- a/app/api/analytics/user-agents/route.ts +++ b/app/api/analytics/user-agents/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { getAnalyticsUserAgents, INTERVAL_SECONDS } from '@/src/lib/analytics-db'; export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { searchParams } = req.nextUrl; const hostsParam = searchParams.get('hosts') ?? ''; const hosts = hostsParam ? hostsParam.split(',').filter(Boolean) : []; diff --git a/app/api/analytics/waf-stats/route.ts b/app/api/analytics/waf-stats/route.ts index 93de18a1..2ce559f8 100644 --- a/app/api/analytics/waf-stats/route.ts +++ b/app/api/analytics/waf-stats/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireUser } from '@/src/lib/auth'; +import { requireAdmin } from '@/src/lib/auth'; import { INTERVAL_SECONDS } from '@/src/lib/analytics-db'; import { countWafEventsInRange, getTopWafRulesWithHosts, getWafEventCountries } from '@/src/lib/models/waf-events'; @@ -16,7 +16,7 @@ function resolveRange(params: URLSearchParams): { from: number; to: number } { } export async function GET(req: NextRequest) { - await requireUser(); + await requireAdmin(); const { from, to } = resolveRange(req.nextUrl.searchParams); const [total, topRules, byCountry] = await Promise.all([ countWafEventsInRange(from, to), diff --git a/app/api/geoip-status/route.ts b/app/api/geoip-status/route.ts index 6ffa512a..bedcce15 100644 --- a/app/api/geoip-status/route.ts +++ b/app/api/geoip-status/route.ts @@ -1,12 +1,12 @@ import { existsSync } from "node:fs"; import { NextResponse } from "next/server"; -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; const COUNTRY_DB = "/usr/share/GeoIP/GeoLite2-Country.mmdb"; const ASN_DB = "/usr/share/GeoIP/GeoLite2-ASN.mmdb"; export async function GET() { - await requireUser(); + await requireAdmin(); return NextResponse.json({ country: existsSync(COUNTRY_DB), asn: existsSync(ASN_DB), diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts index 06824fde..cfe5de96 100644 --- a/app/api/v1/openapi.json/route.ts +++ b/app/api/v1/openapi.json/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; const spec = { openapi: "3.1.0", @@ -1768,10 +1769,15 @@ const spec = { }, }; -export async function GET() { +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + } catch (error) { + return apiErrorResponse(error); + } return NextResponse.json(spec, { headers: { - "Cache-Control": "public, max-age=3600", + "Cache-Control": "private, max-age=3600", }, }); } diff --git a/tests/unit/api-routes/openapi.test.ts b/tests/unit/api-routes/openapi.test.ts index 7cfb36bd..d4f3ead5 100644 --- a/tests/unit/api-routes/openapi.test.ts +++ b/tests/unit/api-routes/openapi.test.ts @@ -1,20 +1,46 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse: NR } = require('next/server'); + if (error instanceof ApiAuthError) { + return NR.json({ error: error.message }, { status: error.status }); + } + return NR.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + import { GET } from '@/app/api/v1/openapi.json/route'; +function makeRequest() { + return new NextRequest('http://localhost/api/v1/openapi.json', { + headers: { authorization: 'Bearer test-token' }, + }); +} + describe('GET /api/v1/openapi.json', () => { it('returns 200', async () => { - const response = await GET(); + const response = await GET(makeRequest()); expect(response.status).toBe(200); }); it('returns valid JSON with openapi field = "3.1.0"', async () => { - const response = await GET(); + const response = await GET(makeRequest()); const data = await response.json(); expect(data.openapi).toBe('3.1.0'); }); it('contains all expected paths', async () => { - const response = await GET(); + const response = await GET(makeRequest()); const data = await response.json(); const paths = Object.keys(data.paths); @@ -33,12 +59,12 @@ describe('GET /api/v1/openapi.json', () => { }); it('has Cache-Control header', async () => { - const response = await GET(); - expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600'); + const response = await GET(makeRequest()); + expect(response.headers.get('Cache-Control')).toBe('private, max-age=3600'); }); it('has components.schemas defined', async () => { - const response = await GET(); + const response = await GET(makeRequest()); const data = await response.json(); expect(data.components).toBeDefined(); expect(data.components.schemas).toBeDefined();