From a78c87fbd94585d706225ff359222170784669d2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:53:12 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20SSRF=20in=20bulk=20lookup=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aicoder2009 <127642633+aicoder2009@users.noreply.github.com> --- .jules/sentinel.md | 4 +++ src/app/api/lookup/bulk/route.test.ts | 44 ++++++++++++++++----------- src/app/api/lookup/bulk/route.ts | 28 +++++++++-------- 3 files changed, 46 insertions(+), 30 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 88805da..409097c 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`. +## 2026-07-02 - [SSRF via loopback fetch using request.nextUrl.origin] +**Vulnerability:** The application was using `fetch(`${request.nextUrl.origin}/api/...`)` to call internal API routes from within the server (specifically in `src/app/api/lookup/bulk/route.ts`). This creates a Server-Side Request Forgery (SSRF) vulnerability because `request.nextUrl.origin` relies on the user-controllable `Host` header, allowing an attacker to redirect internal traffic or conduct request smuggling. +**Learning:** Relying on dynamically derived hostnames for internal loopback requests is dangerous. Next.js Route Handlers are simply JavaScript functions and can be invoked directly. +**Prevention:** When calling internal Next.js API endpoints from within the server, always invoke the exported route handler functions directly instead of using `fetch()` with dynamically derived hostnames. If the handler expects a `NextRequest`, construct a synthetic one (e.g., `new NextRequest(new URL('http://localhost'), { method: 'POST', body: ... })`). diff --git a/src/app/api/lookup/bulk/route.test.ts b/src/app/api/lookup/bulk/route.test.ts index 7c14ffd..cc95db7 100644 --- a/src/app/api/lookup/bulk/route.test.ts +++ b/src/app/api/lookup/bulk/route.test.ts @@ -2,7 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { POST } from './route'; import { NextRequest } from 'next/server'; -global.fetch = vi.fn(); +// Mock the internal route handlers directly since we no longer use fetch +vi.mock('../url/route', () => ({ POST: vi.fn() })); +vi.mock('../doi/route', () => ({ POST: vi.fn() })); +vi.mock('../isbn/route', () => ({ POST: vi.fn() })); + +import { POST as urlLookup } from '../url/route'; +import { POST as doiLookup } from '../doi/route'; +import { POST as isbnLookup } from '../isbn/route'; function makeRequest(body: object) { return new NextRequest('http://localhost/api/lookup/bulk', { @@ -54,7 +61,7 @@ describe('Bulk Lookup API', () => { }); it('routes URLs to /api/lookup/url', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (urlLookup as ReturnType).mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'Example Page' } }), }); @@ -62,36 +69,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(urlLookup).toHaveBeenCalled(); }); it('routes DOIs to /api/lookup/doi', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (doiLookup 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(doiLookup).toHaveBeenCalled(); }); it('routes ISBNs to /api/lookup/isbn', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (isbnLookup 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(isbnLookup).toHaveBeenCalled(); }); it('marks item as failed when sub-request fails', async () => { - (global.fetch as ReturnType).mockResolvedValueOnce({ + (doiLookup as ReturnType).mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'Not found' }), }); @@ -102,9 +106,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({ + ok: true, json: async () => ({ data: { title: 'A' } }) + }); + (doiLookup as ReturnType).mockResolvedValueOnce({ + ok: false, json: async () => ({ error: 'fail' }) + }); const response = await POST( makeRequest({ items: ['https://success.com', '10.1000/fail'] }) ); @@ -115,9 +122,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({ + ok: true, json: async () => ({ data: { title: 'URL result' } }) + }); + (doiLookup 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..312634d 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 "../url/route"; +import { POST as doiLookup } from "../doi/route"; +import { POST as isbnLookup } 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,19 +32,19 @@ export async function POST(request: NextRequest) { } try { - let apiEndpoint: string; - let body: object; + let lookupHandler: (req: NextRequest) => Promise; + let handlerBody: object; // Detect input type if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) { - apiEndpoint = "/api/lookup/url"; - body = { url: trimmedItem }; + lookupHandler = urlLookup; + handlerBody = { url: trimmedItem }; } else if (trimmedItem.match(/^10\.\d{4,}/)) { - apiEndpoint = "/api/lookup/doi"; - body = { doi: trimmedItem }; + lookupHandler = doiLookup; + handlerBody = { doi: trimmedItem }; } else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) { - apiEndpoint = "/api/lookup/isbn"; - body = { isbn: trimmedItem }; + lookupHandler = isbnLookup; + handlerBody = { isbn: trimmedItem }; } else { return { input: trimmedItem, @@ -52,13 +53,14 @@ export async function POST(request: NextRequest) { }; } - // Make the API call - const response = await fetch(`${baseUrl}${apiEndpoint}`, { + // Make the internal handler call directly to prevent SSRF + const syntheticRequest = new NextRequest(new URL('http://localhost'), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify(handlerBody), }); + const response = await lookupHandler(syntheticRequest); const data = await response.json(); if (response.ok && data.data) {