From 6e10a84bc3c3a7eb8302fb6127aac495108f3d17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 11:04:42 +0000 Subject: [PATCH] fix(security): require auth on Ogham generate-report Groq route Unauthenticated POST /api/tools/ogham/generate-report called Groq and read/wrote Admin oghamReadings by body userId. Gate the HTTP route to the signed-in owner and run Stage B in-process so generation still works. Co-authored-by: ANDY OLIVER ROZARIO --- app/api/tools/ogham/generate-report/route.ts | 57 +++++--- lib/profileGenerationOrchestrator.ts | 19 +-- .../ogham-generate-report-auth.test.ts | 135 ++++++++++++++++++ 3 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 tests/integration/ogham-generate-report-auth.test.ts diff --git a/app/api/tools/ogham/generate-report/route.ts b/app/api/tools/ogham/generate-report/route.ts index dc5dd695..1aa4242d 100644 --- a/app/api/tools/ogham/generate-report/route.ts +++ b/app/api/tools/ogham/generate-report/route.ts @@ -1,51 +1,66 @@ /** * Ogham Report Generation API * Generates comprehensive personalized Ogham reports + * + * Requires a signed-in Firebase user — must not be an unauthenticated Groq + * proxy or Admin Firestore IDOR via body userId (oghamReadings R/W). + * + * Trusted server callers (Stage B) should import `oghamIntelligence.generateReading` + * directly instead of HTTP-looping through this route. */ import { NextRequest, NextResponse } from 'next/server' import { oghamIntelligence } from '@/lib/oghamIntelligence' import { isProfileComplete, UserProfile } from '@/lib/firebase' +import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth' +import { withRateLimit, rateLimiters } from '@/lib/rateLimit' import { devLog } from '@/lib/devLogger' -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { userId, userProfile: providedProfile } = body +async function handlePost(request: NextRequest) { + const auth = await verifyUserRequest(request, 'ogham-generate-report') + if (!auth.ok) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } - if (!userId) { + try { + let body: Record + try { + body = (await request.json()) as Record + } catch { return NextResponse.json( - { success: false, error: 'User ID is required' }, - { status: 400 } + { success: false, error: 'Invalid JSON body' }, + { status: 400 }, ) } - // Use provided profile or require it - const userProfile = providedProfile as UserProfile | null + const ownedUserId = resolveOwnedUserId(body.userId, auth.uid) + if (!ownedUserId) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) + } + + const userProfile = body.userProfile as UserProfile | null if (!userProfile) { return NextResponse.json( { success: false, error: 'User profile is required' }, - { status: 400 } + { status: 400 }, ) } - // Check profile completion if (!isProfileComplete(userProfile)) { return NextResponse.json( - { - success: false, + { + success: false, error: 'Complete birth profile required. Please complete your profile first.', - missingFields: ['birthDate', 'birthTime', 'birthPlace'] + missingFields: ['birthDate', 'birthTime', 'birthPlace'], }, - { status: 400 } + { status: 400 }, ) } - devLog.info('🔮 Generating Ogham report for user:', userId, 'ogham') + devLog.info('🔮 Generating Ogham report for user:', ownedUserId, 'ogham') - // Generate comprehensive Ogham reading - const report = await oghamIntelligence.generateReading(userId, userProfile) + const report = await oghamIntelligence.generateReading(ownedUserId, userProfile) devLog.info('✅ Ogham report generated successfully', undefined, 'ogham') @@ -58,15 +73,17 @@ export async function POST(request: NextRequest) { }) } catch (error) { devLog.error('❌ Error generating Ogham report:', error, 'route') - + return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Failed to generate report', details: process.env.NODE_ENV === 'development' ? String(error) : undefined, }, - { status: 500 } + { status: 500 }, ) } } +export const POST = withRateLimit(handlePost, rateLimiters.ai, 'ogham_generate_report_post') + diff --git a/lib/profileGenerationOrchestrator.ts b/lib/profileGenerationOrchestrator.ts index ecfaf3f1..83046e1e 100644 --- a/lib/profileGenerationOrchestrator.ts +++ b/lib/profileGenerationOrchestrator.ts @@ -1220,18 +1220,13 @@ async function runTool( case 'ogham': { try { - const res = await fetch(`${baseUrl}/api/tools/ogham/generate-report`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId, userProfile: profile }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string })?.error ?? `Ogham API: ${res.status}`); - } - const json = await res.json(); - const data = json.data ?? json; - return { status: 'success', data: (data as Record) ?? {}, generatedAt, _usage: json._usage ?? json.usage }; + const { oghamIntelligence } = await import('@/lib/oghamIntelligence'); + const report = await oghamIntelligence.generateReading(userId, profile); + return { + status: 'success', + data: { report, generatedAt } as unknown as Record, + generatedAt, + }; } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; devLog.warn('[ProfileOrchestrator] Ogham failed:', msg, 'profileGenerationOrchestrator'); diff --git a/tests/integration/ogham-generate-report-auth.test.ts b/tests/integration/ogham-generate-report-auth.test.ts new file mode 100644 index 00000000..a72f76b8 --- /dev/null +++ b/tests/integration/ogham-generate-report-auth.test.ts @@ -0,0 +1,135 @@ +/** + * Ogham generate-report must not be an unauthenticated Groq proxy + * or allow Admin oghamReadings IDOR via body userId. + * @jest-environment node + */ + +import { NextRequest } from 'next/server'; + +const mockVerifyIdToken = jest.fn(); +const mockGenerateReading = jest.fn(); + +jest.mock('@/lib/firebase-admin', () => ({ + getAuth: () => ({ verifyIdToken: mockVerifyIdToken }), +})); + +jest.mock('@/lib/oghamIntelligence', () => ({ + oghamIntelligence: { + generateReading: (...args: unknown[]) => mockGenerateReading(...args), + }, +})); + +jest.mock('@/lib/rateLimitFirestore', () => ({ + checkRateLimitWithOptionalFirestore: async ( + limiter: { check: (identifier: string) => { allowed: boolean; remaining: number; resetTime: number } }, + _logicalKey: string, + identifier: string, + ) => limiter.check(identifier), +})); + +import { POST } from '@/app/api/tools/ogham/generate-report/route'; + +const completeProfile = { + birthDate: '1990-01-15', + birthTime: '14:30:00', + birthPlace: 'Dublin', +}; + +function makeBody(overrides: Record = {}) { + return { + userId: 'user-1', + userProfile: completeProfile, + ...overrides, + }; +} + +describe('POST /api/tools/ogham/generate-report auth', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGenerateReading.mockResolvedValue({ + id: 'ogham_owned', + overview: 'owned ogham report', + }); + }); + + it('rejects missing Authorization without Groq or Firestore', async () => { + const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(makeBody({ userId: 'victim' })), + }); + + const res = await POST(req); + expect(res.status).toBe(401); + expect(mockVerifyIdToken).not.toHaveBeenCalled(); + expect(mockGenerateReading).not.toHaveBeenCalled(); + }); + + it('rejects invalid token without Groq or Firestore', async () => { + mockVerifyIdToken.mockRejectedValueOnce(new Error('bad token')); + const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer bad', + }, + body: JSON.stringify(makeBody({ userId: 'victim' })), + }); + + const res = await POST(req); + expect(res.status).toBe(401); + expect(mockGenerateReading).not.toHaveBeenCalled(); + }); + + it('rejects userId mismatch (oghamReadings IDOR) without Groq', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'attacker', email: 'a@b.c' }); + const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify(makeBody({ userId: 'victim' })), + }); + + const res = await POST(req); + expect(res.status).toBe(403); + expect(mockGenerateReading).not.toHaveBeenCalled(); + }); + + it('rejects missing userId without Groq', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' }); + const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify({ userProfile: completeProfile }), + }); + + const res = await POST(req); + expect(res.status).toBe(403); + expect(mockGenerateReading).not.toHaveBeenCalled(); + }); + + it('allows owned userId and generates without treating body userId as victim', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' }); + const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify(makeBody()), + }); + + const res = await POST(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + expect(json.data.report.id).toBe('ogham_owned'); + expect(mockGenerateReading).toHaveBeenCalledTimes(1); + expect(mockGenerateReading).toHaveBeenCalledWith('user-1', completeProfile); + }); +});