From 68fce5730d30a0ad0264e069ebc14ecab1823a50 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 11:22:16 +0000 Subject: [PATCH] fix(security): require auth for community connection requests Unauthenticated callers could read private connection messages, send requests as any userId, and accept/decline as any recipient. Bind GET/POST/PATCH to the Firebase ID token and send Bearer from the community page. Co-authored-by: Andy Oliver Rozario --- app/api/community/connections/[id]/route.ts | 18 +- app/api/community/connections/route.ts | 30 +- app/community/attribution/page.tsx | 10 +- .../community-connections-auth.test.ts | 256 ++++++++++++++++++ 4 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 tests/integration/community-connections-auth.test.ts diff --git a/app/api/community/connections/[id]/route.ts b/app/api/community/connections/[id]/route.ts index 7442e33b..e548fc69 100644 --- a/app/api/community/connections/[id]/route.ts +++ b/app/api/community/connections/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { adminDb } from '@/lib/firebase-admin'; +import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth'; export const dynamic = 'force-dynamic'; @@ -17,11 +18,24 @@ export async function PATCH( return NextResponse.json({ error: 'Not available in static export' }, { status: 404 }) } try { + const auth = await verifyUserRequest(request, 'community-connections'); + if (!auth.ok) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { id: connectionId } = await params; const body = await request.json(); - const { action, userId } = body; // action: 'accept' | 'decline' + const { action } = body; // action: 'accept' | 'decline' + const userId = resolveOwnedUserId(body.userId, auth.uid); + + if (!userId) { + return NextResponse.json( + { error: 'userId is required and must match the authenticated user' }, + { status: 403 } + ); + } - if (!action || !userId) { + if (!action) { return NextResponse.json( { error: 'Missing required fields: action, userId' }, { status: 400 } diff --git a/app/api/community/connections/route.ts b/app/api/community/connections/route.ts index a6fd7174..136e7d1b 100644 --- a/app/api/community/connections/route.ts +++ b/app/api/community/connections/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import type { Query, QueryDocumentSnapshot } from 'firebase-admin/firestore'; import { devLog } from '@/lib/devLogger'; import { adminDb } from '@/lib/firebase-admin'; +import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth'; // Must be dynamic: GET uses searchParams + Firestore; force-static breaks per-request handling. export const dynamic = 'force-dynamic'; @@ -84,10 +85,23 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Not available in static export' }, { status: 404 }) } try { + const auth = await verifyUserRequest(request, 'community-connections'); + if (!auth.ok) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const body: ConnectionRequestData = await request.json(); - const { fromUserId, fromUserName, toUserId, toUserName, topic, message } = body; + const { fromUserName, toUserId, toUserName, topic, message } = body; + const fromUserId = resolveOwnedUserId(body.fromUserId, auth.uid); + + if (!fromUserId) { + return NextResponse.json( + { error: 'fromUserId is required and must match the authenticated user' }, + { status: 403 } + ); + } - if (!fromUserId || !fromUserName || !toUserId || !toUserName || !topic || !message) { + if (!fromUserName || !toUserId || !toUserName || !topic || !message) { return NextResponse.json( { error: 'Missing required fields: fromUserId, fromUserName, toUserId, toUserName, topic, message' }, { status: 400 } @@ -169,12 +183,20 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Not available in static export' }, { status: 404 }) } try { + const auth = await verifyUserRequest(request, 'community-connections'); + if (!auth.ok) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { searchParams } = new URL(request.url); - const userId = searchParams.get('userId'); + const userId = resolveOwnedUserId(searchParams.get('userId'), auth.uid); const type = searchParams.get('type') || 'all'; // all, incoming, outgoing if (!userId) { - return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); + return NextResponse.json( + { error: 'User ID is required and must match the authenticated user' }, + { status: 403 } + ); } const db = adminDb; diff --git a/app/community/attribution/page.tsx b/app/community/attribution/page.tsx index 9b2da82d..9a0308f8 100644 --- a/app/community/attribution/page.tsx +++ b/app/community/attribution/page.tsx @@ -19,6 +19,8 @@ import { RecaptchaScript } from '@/components/RecaptchaScript'; import { useToast } from '@/components/ui/use-toast'; import { getReturningPaymentCommitDestination } from '@/lib/authRouting'; import { analytics } from '@/lib/analytics'; +import { fetchWithFirebaseAuthRequired } from '@/lib/clientFirebaseFetch'; + interface UserContribution { id: string; type: 'feedback' | 'suggestion' | 'bug-report' | 'feature-request'; @@ -230,7 +232,7 @@ export default function CommunityAttributionPage() { return null; }); - const requestsPromise = fetch(`/api/community/connections?userId=${uid}&type=incoming`) + const requestsPromise = fetchWithFirebaseAuthRequired(`/api/community/connections?userId=${uid}&type=incoming`) .then(async (r) => { if (!r.ok) return null; const data = await r.json(); @@ -496,7 +498,7 @@ export default function CommunityAttributionPage() { if (!user?.uid) return; setRequestsLoading(true); try { - const response = await fetch(`/api/community/connections?userId=${user.uid}&type=all`); + const response = await fetchWithFirebaseAuthRequired(`/api/community/connections?userId=${user.uid}&type=all`); if (!response.ok) throw new Error('Failed to load requests'); const data = await response.json(); const requests = (data.requests ?? []).map((request: Record) => ({ @@ -522,7 +524,7 @@ export default function CommunityAttributionPage() { const respondToConnectionRequest = async (requestId: string, action: 'accept' | 'decline') => { if (!user?.uid) return; try { - const response = await fetch(`/api/community/connections/${requestId}`, { + const response = await fetchWithFirebaseAuthRequired(`/api/community/connections/${requestId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, userId: user.uid }), @@ -557,7 +559,7 @@ export default function CommunityAttributionPage() { } try { - const response = await fetch('/api/community/connections', { + const response = await fetchWithFirebaseAuthRequired('/api/community/connections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/tests/integration/community-connections-auth.test.ts b/tests/integration/community-connections-auth.test.ts new file mode 100644 index 00000000..fae5ba83 --- /dev/null +++ b/tests/integration/community-connections-auth.test.ts @@ -0,0 +1,256 @@ +/** + * Integration tests: /api/community/connections + * Unauthenticated callers must not read private messages or impersonate senders/recipients. + * @jest-environment node + */ + +import { NextRequest } from 'next/server'; + +const mockVerifyUserRequest = jest.fn(); +const mockQueryGet = jest.fn(); +const mockDocGet = jest.fn(); +const mockDocSet = jest.fn(); +const mockDocUpdate = jest.fn(); + +function chainableQuery() { + const query = { + where: jest.fn(() => query), + orderBy: jest.fn(() => query), + get: mockQueryGet, + }; + return query; +} + +jest.mock('@/lib/userApiAuth', () => ({ + verifyUserRequest: (...args: unknown[]) => mockVerifyUserRequest(...args), + resolveOwnedUserId: (requested: unknown, authUid: string) => + typeof requested === 'string' && requested.trim() === authUid ? requested.trim() : null, +})); + +jest.mock('@/lib/firebase-admin', () => ({ + adminDb: { + collection: () => { + const query = chainableQuery(); + return { + where: query.where, + orderBy: query.orderBy, + get: mockQueryGet, + doc: (id?: string) => ({ + id: id || 'conn-new', + get: mockDocGet, + set: mockDocSet, + update: mockDocUpdate, + }), + }; + }, + }, +})); + +import { GET, POST } from '@/app/api/community/connections/route'; +import { PATCH } from '@/app/api/community/connections/[id]/route'; + +describe('community connections auth', () => { + const victimUid = 'victim-uid'; + const attackerUid = 'attacker-uid'; + const privateMessage = 'Let us talk about my birth chart privately'; + + beforeEach(() => { + jest.clearAllMocks(); + mockQueryGet.mockResolvedValue({ empty: true, docs: [] }); + mockDocSet.mockResolvedValue(undefined); + mockDocUpdate.mockResolvedValue(undefined); + mockDocGet.mockResolvedValue({ + exists: true, + id: 'conn-1', + data: () => ({ + fromUserId: attackerUid, + fromUserName: 'Attacker', + toUserId: victimUid, + toUserName: 'Victim', + topic: 'Chart reading', + message: privateMessage, + status: 'pending', + createdAt: { toDate: () => new Date('2026-08-01T00:00:00.000Z') }, + }), + }); + }); + + function jsonHeaders(authHeader: boolean): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (authHeader) headers.Authorization = 'Bearer test-token'; + return headers; + } + + async function postConnection( + body: Record, + authHeader = true, + ): Promise { + const req = new NextRequest('http://localhost:3000/api/community/connections', { + method: 'POST', + headers: jsonHeaders(authHeader), + body: JSON.stringify(body), + }); + return POST(req) as Promise; + } + + async function getConnections( + userId: string, + authHeader = true, + type = 'all', + ): Promise { + const req = new NextRequest( + `http://localhost:3000/api/community/connections?userId=${userId}&type=${type}`, + { + method: 'GET', + headers: jsonHeaders(authHeader), + }, + ); + return GET(req) as Promise; + } + + async function patchConnection( + body: Record, + authHeader = true, + ): Promise { + const req = new NextRequest('http://localhost:3000/api/community/connections/conn-1', { + method: 'PATCH', + headers: jsonHeaders(authHeader), + body: JSON.stringify(body), + }); + return PATCH(req, { params: Promise.resolve({ id: 'conn-1' }) }) as Promise; + } + + const validPostBody = { + fromUserId: victimUid, + fromUserName: 'Impostor', + toUserId: attackerUid, + toUserName: 'Target', + topic: 'Hello', + message: 'Forged request', + }; + + describe('GET', () => { + it('returns 401 and does not query when Authorization is missing', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: false, reason: 'missing_token' }); + + const res = await getConnections(victimUid, false); + + expect(res.status).toBe(401); + expect(mockQueryGet).not.toHaveBeenCalled(); + }); + + it('returns 403 and does not leak another user private messages', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid }); + + const res = await getConnections(victimUid); + + expect(res.status).toBe(403); + expect(mockQueryGet).not.toHaveBeenCalled(); + }); + + it('returns the authenticated user connections when userId matches the token', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: victimUid }); + mockQueryGet.mockResolvedValue({ + empty: false, + docs: [ + { + id: 'conn-1', + data: () => ({ + fromUserId: attackerUid, + fromUserName: 'Attacker', + toUserId: victimUid, + toUserName: 'Victim', + topic: 'Chart reading', + message: privateMessage, + status: 'pending', + createdAt: { toDate: () => new Date('2026-08-01T00:00:00.000Z') }, + }), + }, + ], + }); + + const res = await getConnections(victimUid, true, 'incoming'); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.requests).toHaveLength(1); + expect(body.requests[0].message).toBe(privateMessage); + }); + }); + + describe('POST', () => { + it('returns 401 and does not write when Authorization is missing', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: false, reason: 'missing_token' }); + + const res = await postConnection(validPostBody, false); + + expect(res.status).toBe(401); + expect(mockDocSet).not.toHaveBeenCalled(); + }); + + it('returns 403 and does not send as another user when fromUserId mismatches the token', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid }); + + const res = await postConnection(validPostBody); + + expect(res.status).toBe(403); + expect(mockDocSet).not.toHaveBeenCalled(); + }); + + it('creates a request when fromUserId matches the token', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid }); + + const res = await postConnection({ + fromUserId: attackerUid, + fromUserName: 'Attacker', + toUserId: victimUid, + toUserName: 'Victim', + topic: 'Hello', + message: 'Want to connect', + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockDocSet).toHaveBeenCalledTimes(1); + const stored = mockDocSet.mock.calls[0][0] as Record; + expect(stored.fromUserId).toBe(attackerUid); + expect(stored.toUserId).toBe(victimUid); + }); + }); + + describe('PATCH', () => { + it('returns 401 and does not update when Authorization is missing', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: false, reason: 'missing_token' }); + + const res = await patchConnection({ action: 'accept', userId: victimUid }, false); + + expect(res.status).toBe(401); + expect(mockDocUpdate).not.toHaveBeenCalled(); + }); + + it('returns 403 and does not accept as another user when userId mismatches the token', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid }); + + const res = await patchConnection({ action: 'accept', userId: victimUid }); + + expect(res.status).toBe(403); + expect(mockDocUpdate).not.toHaveBeenCalled(); + }); + + it('accepts when the authenticated user is the recipient', async () => { + mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: victimUid }); + + const res = await patchConnection({ action: 'accept', userId: victimUid }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(mockDocUpdate).toHaveBeenCalledTimes(1); + expect(mockDocUpdate.mock.calls[0][0]).toEqual( + expect.objectContaining({ status: 'accepted' }), + ); + }); + }); +});