From 6c134387257afccc9a00e9e2a35b3cbce6c9240b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:35:46 +0000 Subject: [PATCH] Fix SSRF vulnerability via Host header in bulk lookup API Co-authored-by: aicoder2009 <127642633+aicoder2009@users.noreply.github.com> --- .jules/sentinel.md | 4 ++ src/app/api/lookup/bulk/route.test.ts | 73 ++++++++++++++++----------- src/app/api/lookup/bulk/route.ts | 31 +++++++----- 3 files changed, 65 insertions(+), 43 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 88805da..85d4e42 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-02-28 - [SSRF via Host-header controlled loopback requests] +**Vulnerability:** The bulk lookup API (`src/app/api/lookup/bulk/route.ts`) was deriving the base URL for internal API requests dynamically using `request.nextUrl.origin`, which relies on the `Host` or `X-Forwarded-Host` HTTP header, and then performing `fetch` requests to itself. +**Learning:** This constitutes a Server-Side Request Forgery (SSRF) and potential Host Header Injection vulnerability. If an attacker controls the `Host` header (e.g., passing a malicious domain), the application will make server-side HTTP requests to the attacker's server, leaking data or exposing internal network capabilities. +**Prevention:** When a Next.js server-side route needs to invoke logic from another internal route, never use `fetch` with dynamically derived hostnames. Instead, directly import and invoke the exported route handler functions (e.g., `POST(syntheticRequest)`) to bypass network traversal entirely and guarantee the call executes internally. diff --git a/src/app/api/lookup/bulk/route.test.ts b/src/app/api/lookup/bulk/route.test.ts index 7c14ffd..57e7bbb 100644 --- a/src/app/api/lookup/bulk/route.test.ts +++ b/src/app/api/lookup/bulk/route.test.ts @@ -1,8 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { POST } from './route'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -global.fetch = vi.fn(); +vi.mock('@/app/api/lookup/url/route', () => ({ + POST: vi.fn(), +})); + +vi.mock('@/app/api/lookup/doi/route', () => ({ + POST: vi.fn(), +})); + +vi.mock('@/app/api/lookup/isbn/route', () => ({ + POST: vi.fn(), +})); + +import { POST as urlLookup } from '@/app/api/lookup/url/route'; +import { POST as doiLookup } from '@/app/api/lookup/doi/route'; +import { POST as isbnLookup } from '@/app/api/lookup/isbn/route'; function makeRequest(body: object) { return new NextRequest('http://localhost/api/lookup/bulk', { @@ -54,47 +68,40 @@ describe('Bulk Lookup API', () => { }); it('routes URLs to /api/lookup/url', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { title: 'Example Page' } }), - }); + (urlLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ data: { title: 'Example Page' } }, { status: 200 }) + ); const response = await POST(makeRequest({ items: ['https://example.com'] })); 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(urlLookup).toHaveBeenCalledTimes(1); }); it('routes DOIs to /api/lookup/doi', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { title: 'Research Article' } }), - }); + (doiLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ data: { title: 'Research Article' } }, { status: 200 }) + ); 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(doiLookup).toHaveBeenCalledTimes(1); }); it('routes ISBNs to /api/lookup/isbn', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { title: 'Book Title' } }), - }); + (isbnLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ data: { title: 'Book Title' } }, { status: 200 }) + ); 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(isbnLookup).toHaveBeenCalledTimes(1); }); it('marks item as failed when sub-request fails', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: false, - json: async () => ({ error: 'Not found' }), - }); + (doiLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ error: 'Not found' }, { status: 404 }) + ); const response = await POST(makeRequest({ items: ['10.1000/nonexistent'] })); const data = await response.json(); expect(data.results[0].success).toBe(false); @@ -102,9 +109,12 @@ 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' }) }); + (urlLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ data: { title: 'A' } }, { status: 200 }) + ); + (doiLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ error: 'fail' }, { status: 404 }) + ); const response = await POST( makeRequest({ items: ['https://success.com', '10.1000/fail'] }) ); @@ -115,9 +125,12 @@ 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' } }) }); + (urlLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ data: { title: 'URL result' } }, { status: 200 }) + ); + (doiLookup as ReturnType).mockResolvedValueOnce( + NextResponse.json({ data: { title: 'DOI result' } }, { status: 200 }) + ); 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..439e2ad 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 urlLookup } from "@/app/api/lookup/url/route"; +import { POST as doiLookup } from "@/app/api/lookup/doi/route"; +import { POST as isbnLookup } from "@/app/api/lookup/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,19 +32,23 @@ export async function POST(request: NextRequest) { } try { - let apiEndpoint: string; - let body: object; + let handler: (req: NextRequest) => Promise; + let handlerUrl: string; + let handlerBody: object; // Detect input type if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) { - apiEndpoint = "/api/lookup/url"; - body = { url: trimmedItem }; + handler = urlLookup; + handlerUrl = "http://localhost/api/lookup/url"; + handlerBody = { url: trimmedItem }; } else if (trimmedItem.match(/^10\.\d{4,}/)) { - apiEndpoint = "/api/lookup/doi"; - body = { doi: trimmedItem }; + handler = doiLookup; + handlerUrl = "http://localhost/api/lookup/doi"; + handlerBody = { doi: trimmedItem }; } else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) { - apiEndpoint = "/api/lookup/isbn"; - body = { isbn: trimmedItem }; + handler = isbnLookup; + handlerUrl = "http://localhost/api/lookup/isbn"; + handlerBody = { isbn: trimmedItem }; } else { return { input: trimmedItem, @@ -52,13 +57,13 @@ export async function POST(request: NextRequest) { }; } - // Make the API call - const response = await fetch(`${baseUrl}${apiEndpoint}`, { + const syntheticRequest = new NextRequest(handlerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify(handlerBody), }); + const response = await handler(syntheticRequest); const data = await response.json(); if (response.ok && data.data) {