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`.
## 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`.
35 changes: 20 additions & 15 deletions src/app/api/lookup/bulk/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -54,44 +60,41 @@ describe('Bulk Lookup API', () => {
});

it('routes URLs to /api/lookup/url', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
(mockLookupUrl as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Example Page' } }),
});
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(mockLookupUrl).toHaveBeenCalled();
});

it('routes DOIs to /api/lookup/doi', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
(mockLookupDoi as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
(mockLookupIsbn as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
(mockLookupDoi as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Not found' }),
});
Expand All @@ -102,8 +105,9 @@ describe('Bulk Lookup API', () => {
});

it('returns summary counts', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'A' } }) })
(mockLookupUrl as unknown as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'A' } }) });
(mockLookupDoi as unknown 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,8 +119,9 @@ describe('Bulk Lookup API', () => {
});

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' } }) })
(mockLookupUrl as unknown as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'URL result' } }) });
(mockLookupDoi as unknown 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
18 changes: 10 additions & 8 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 lookupUrl } from "../url/route";
import { POST as lookupDoi } from "../doi/route";
import { POST as lookupIsbn } from "../isbn/route";

interface LookupResult {
input: string;
Expand All @@ -22,27 +25,25 @@ 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 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 {
Expand All @@ -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) {
Expand Down