diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 88805da..b2afd5f 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -6,3 +6,8 @@ **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-06-29 - Fix SSRF in bulk lookup API +**Vulnerability:** The bulk lookup API (`/api/lookup/bulk`) used `fetch()` with dynamically derived hostnames (`request.nextUrl.origin`) for loopback requests to internal route handlers. This is an SSRF vulnerability because `request.nextUrl.origin` is constructed using the user-controllable `Host` header. +**Learning:** Using `request.nextUrl.origin` inside internal `fetch` requests can allow attackers to spoof the `Host` header and redirect internal server requests to arbitrary domains. +**Prevention:** Call Next.js API route handlers directly as imported functions with synthetic `NextRequest` objects instead of performing HTTP loopback requests. diff --git a/src/app/api/lookup/bulk/route.test.ts b/src/app/api/lookup/bulk/route.test.ts index 7c14ffd..e25df4d 100644 --- a/src/app/api/lookup/bulk/route.test.ts +++ b/src/app/api/lookup/bulk/route.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { POST } from './route'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { POST as urlHandler } from '../url/route'; +import { POST as doiHandler } from '../doi/route'; +import { POST as isbnHandler } from '../isbn/route'; + +vi.mock('../url/route', () => ({ POST: vi.fn() })); +vi.mock('../doi/route', () => ({ POST: vi.fn() })); +vi.mock('../isbn/route', () => ({ POST: vi.fn() })); -global.fetch = vi.fn(); function makeRequest(body: object) { return new NextRequest('http://localhost/api/lookup/bulk', { @@ -53,8 +59,8 @@ describe('Bulk Lookup API', () => { expect(data.results[0].error).toMatch(/Unrecognized/i); }); - it('routes URLs to /api/lookup/url', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + it('routes URLs to urlHandler', async () => { + (urlHandler 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(urlHandler).toHaveBeenCalled(); }); - it('routes DOIs to /api/lookup/doi', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + it('routes DOIs to doiHandler', async () => { + (doiHandler 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(doiHandler).toHaveBeenCalled(); }); - it('routes ISBNs to /api/lookup/isbn', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + it('routes ISBNs to isbnHandler', async () => { + (isbnHandler 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(isbnHandler).toHaveBeenCalled(); }); it('marks item as failed when sub-request fails', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (doiHandler as ReturnType).mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'Not found' }), }); @@ -102,9 +105,14 @@ describe('Bulk Lookup API', () => { }); it('returns summary counts', async () => { - (global.fetch as ReturnType) - .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'A' } }) }) - .mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'fail' }) }); + (urlHandler as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { title: 'A' } }), + }); + (doiHandler as ReturnType).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: 'fail' }), + }); const response = await POST( makeRequest({ items: ['https://success.com', '10.1000/fail'] }) ); @@ -115,9 +123,14 @@ 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' } }) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'DOI result' } }) }); + (urlHandler as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { title: 'URL result' } }), + }); + (doiHandler 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..aa72bc2 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 urlHandler } from "../url/route"; +import { POST as doiHandler } from "../doi/route"; +import { POST as isbnHandler } from "../isbn/route"; interface LookupResult { input: string; @@ -22,7 +25,7 @@ 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(); @@ -31,19 +34,19 @@ export async function POST(request: NextRequest) { } try { - let apiEndpoint: string; - let body: object; + let handler: typeof urlHandler; + let handlerBody: object; // Detect input type if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) { - apiEndpoint = "/api/lookup/url"; - body = { url: trimmedItem }; + handler = urlHandler; + handlerBody = { url: trimmedItem }; } else if (trimmedItem.match(/^10\.\d{4,}/)) { - apiEndpoint = "/api/lookup/doi"; - body = { doi: trimmedItem }; + handler = doiHandler; + handlerBody = { doi: trimmedItem }; } else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) { - apiEndpoint = "/api/lookup/isbn"; - body = { isbn: trimmedItem }; + handler = isbnHandler; + handlerBody = { isbn: trimmedItem }; } else { return { input: trimmedItem, @@ -52,13 +55,13 @@ export async function POST(request: NextRequest) { }; } - // Make the API call - const response = await fetch(`${baseUrl}${apiEndpoint}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + // Direct function call to prevent SSRF + const req = new NextRequest(new URL('http://localhost'), { + method: 'POST', + body: JSON.stringify(handlerBody), }); + const response = await handler(req); const data = await response.json(); if (response.ok && data.data) {