Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ... })`).
44 changes: 27 additions & 17 deletions src/app/api/lookup/bulk/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@
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', {
Expand Down Expand Up @@ -54,44 +61,41 @@
});

it('routes URLs to /api/lookup/url', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
(urlLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Example Page' } }),

Check warning on line 66 in src/app/api/lookup/bulk/route.test.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test, build

Async method 'json' has no 'await' expression
});
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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
(doiLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Research Article' } }),

Check warning on line 78 in src/app/api/lookup/bulk/route.test.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test, build

Async method 'json' has no 'await' expression
});
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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
(isbnLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Book Title' } }),

Check warning on line 89 in src/app/api/lookup/bulk/route.test.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test, build

Async method 'json' has no 'await' expression
});
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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
(doiLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Not found' }),
});
Expand All @@ -102,9 +106,12 @@
});

it('returns summary counts', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'A' } }) })
.mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'fail' }) });
(urlLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, json: async () => ({ data: { title: 'A' } })
});
(doiLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false, json: async () => ({ error: 'fail' })
});
const response = await POST(
makeRequest({ items: ['https://success.com', '10.1000/fail'] })
);
Expand All @@ -115,9 +122,12 @@
});

it('handles mixed item types in one batch', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'URL result' } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'DOI result' } }) });
(urlLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, json: async () => ({ data: { title: 'URL result' } })
});
(doiLookup as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, json: async () => ({ data: { title: 'DOI result' } })
});
const response = await POST(
makeRequest({ items: ['https://example.com', '10.1000/abc'] })
);
Expand Down
28 changes: 15 additions & 13 deletions src/app/api/lookup/bulk/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,28 +25,26 @@ 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) {
return { input: item, success: false, error: "Empty input" };
}

try {
let apiEndpoint: string;
let body: object;
let lookupHandler: (req: NextRequest) => Promise<NextResponse>;
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,
Expand All @@ -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) {
Expand Down
Loading