diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 88805da..b3baad6 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -6,3 +6,7 @@ **Vulnerability:** The application was using the `marked` library to parse Markdown content into HTML (in `src/app/docs/changelog/page.tsx` and `src/lib/docs.ts`) and subsequently rendering it using `dangerouslySetInnerHTML` without proper sanitization. **Learning:** `marked` does not sanitize HTML by default. While this may seem safe for trusted inputs (like internal docs or GitHub releases), if malicious input manages to enter these sources, it leads directly to an XSS vulnerability. **Prevention:** The output of `marked` (or any markdown parser) must always be wrapped with `DOMPurify.sanitize()` (using `isomorphic-dompurify` for SSR) before being passed to `dangerouslySetInnerHTML`. +## 2025-05-31 - Prevent SSRF via Internal Fetch Loops +**Vulnerability:** Next.js API route handlers fetching `request.nextUrl.origin` are vulnerable to Host-header SSRF, as attackers can control the origin. +**Learning:** In Next.js App Router, one server route shouldn't `fetch` another using dynamic user-supplied origins. +**Prevention:** Call internal Next.js Route Handler functions directly (e.g. `import { POST } from "../url/route"`) and pass a constructed `NextRequest`. diff --git a/src/app/api/lookup/bulk/route.test.ts b/src/app/api/lookup/bulk/route.test.ts index 7c14ffd..8eb1acc 100644 --- a/src/app/api/lookup/bulk/route.test.ts +++ b/src/app/api/lookup/bulk/route.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { POST } from './route'; import { NextRequest } from 'next/server'; -global.fetch = vi.fn(); +vi.mock('../url/route', () => ({ POST: vi.fn() })); +vi.mock('../doi/route', () => ({ POST: vi.fn() })); +vi.mock('../isbn/route', () => ({ POST: vi.fn() })); + +import { POST as mockLookupUrl } from '../url/route'; +import { POST as mockLookupDoi } from '../doi/route'; +import { POST as mockLookupIsbn } from '../isbn/route'; function makeRequest(body: object) { return new NextRequest('http://localhost/api/lookup/bulk', { @@ -54,7 +60,7 @@ describe('Bulk Lookup API', () => { }); it('routes URLs to /api/lookup/url', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (mockLookupUrl as unknown as ReturnType).mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'Example Page' } }), }); @@ -62,36 +68,33 @@ describe('Bulk Lookup API', () => { const data = await response.json(); expect(data.results[0].success).toBe(true); expect(data.results[0].data.title).toBe('Example Page'); - const [url] = (global.fetch as ReturnType).mock.calls[0] as [string]; - expect(url).toContain('/api/lookup/url'); + expect(mockLookupUrl).toHaveBeenCalled(); }); it('routes DOIs to /api/lookup/doi', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (mockLookupDoi as unknown as ReturnType).mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'Research Article' } }), }); const response = await POST(makeRequest({ items: ['10.1000/xyz123'] })); const data = await response.json(); expect(data.results[0].success).toBe(true); - const [url] = (global.fetch as ReturnType).mock.calls[0] as [string]; - expect(url).toContain('/api/lookup/doi'); + expect(mockLookupDoi).toHaveBeenCalled(); }); it('routes ISBNs to /api/lookup/isbn', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (mockLookupIsbn as unknown as ReturnType).mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'Book Title' } }), }); const response = await POST(makeRequest({ items: ['9780316769174'] })); const data = await response.json(); expect(data.results[0].success).toBe(true); - const [url] = (global.fetch as ReturnType).mock.calls[0] as [string]; - expect(url).toContain('/api/lookup/isbn'); + expect(mockLookupIsbn).toHaveBeenCalled(); }); it('marks item as failed when sub-request fails', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (mockLookupDoi as unknown as ReturnType).mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'Not found' }), }); @@ -102,8 +105,9 @@ describe('Bulk Lookup API', () => { }); it('returns summary counts', async () => { - (global.fetch as ReturnType) - .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'A' } }) }) + (mockLookupUrl as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'A' } }) }); + (mockLookupDoi as unknown as ReturnType) .mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'fail' }) }); const response = await POST( makeRequest({ items: ['https://success.com', '10.1000/fail'] }) @@ -115,8 +119,9 @@ describe('Bulk Lookup API', () => { }); it('handles mixed item types in one batch', async () => { - (global.fetch as ReturnType) - .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'URL result' } }) }) + (mockLookupUrl as unknown as ReturnType) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'URL result' } }) }); + (mockLookupDoi as unknown as ReturnType) .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'DOI result' } }) }); const response = await POST( makeRequest({ items: ['https://example.com', '10.1000/abc'] }) diff --git a/src/app/api/lookup/bulk/route.ts b/src/app/api/lookup/bulk/route.ts index 96b8ed8..eaf614e 100644 --- a/src/app/api/lookup/bulk/route.ts +++ b/src/app/api/lookup/bulk/route.ts @@ -1,4 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import { POST as lookupUrl } from "../url/route"; +import { POST as lookupDoi } from "../doi/route"; +import { POST as lookupIsbn } from "../isbn/route"; interface LookupResult { input: string; @@ -22,8 +25,6 @@ export async function POST(request: NextRequest) { } // Refactored to process lookups concurrently for performance improvement - const baseUrl = request.nextUrl.origin; - const lookupPromises = items.map(async (item) => { const trimmedItem = item.trim(); if (!trimmedItem) { @@ -31,18 +32,18 @@ export async function POST(request: NextRequest) { } try { - let apiEndpoint: string; + let handler; let body: object; // Detect input type if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) { - apiEndpoint = "/api/lookup/url"; + handler = lookupUrl; body = { url: trimmedItem }; } else if (trimmedItem.match(/^10\.\d{4,}/)) { - apiEndpoint = "/api/lookup/doi"; + handler = lookupDoi; body = { doi: trimmedItem }; } else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) { - apiEndpoint = "/api/lookup/isbn"; + handler = lookupIsbn; body = { isbn: trimmedItem }; } else { return { @@ -52,13 +53,14 @@ export async function POST(request: NextRequest) { }; } - // Make the API call - const response = await fetch(`${baseUrl}${apiEndpoint}`, { + // Make the API call directly to the handler + const syntheticReq = new NextRequest(new URL("http://localhost"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + const response = await handler(syntheticReq); const data = await response.json(); if (response.ok && data.data) {