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-02-28 - [SSRF via loopback fetch using user-controlled Host header]
**Vulnerability:** The bulk lookup API (`src/app/api/lookup/bulk/route.ts`) performed loopback HTTP requests to other internal endpoints using `fetch()` and dynamically derived the host via `request.nextUrl.origin`. Because `request.nextUrl.origin` relies on the HTTP `Host` header, an attacker could spoof it, redirecting the internal `fetch()` to an arbitrary external server or internal IP (a Server-Side Request Forgery vulnerability).
**Learning:** Next.js Route Handlers (API endpoints) run on the server side and should not invoke each other via network `fetch()` unless absolutely necessary. When doing so with a user-provided or dynamically derived URL based on request headers, it immediately creates an SSRF risk.
**Prevention:** Rather than using `fetch()` to call internal API endpoints, import their exported `POST`, `GET`, etc., functions directly and invoke them with a synthesized or properly formatted `NextRequest`. This prevents DNS/network routing vulnerabilities and is also more performant.
Comment on lines +9 to +12
64 changes: 34 additions & 30 deletions src/app/api/lookup/bulk/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ import { NextRequest } from 'next/server';

global.fetch = vi.fn();

Comment on lines 4 to 6
vi.mock('@/app/api/lookup/url/route', () => ({
POST: vi.fn(async (req) => {
const data = await req.json();
if (data.url === 'https://example.com' || data.url === 'https://url-test.com') {
return new Response(JSON.stringify({ data: { title: data.url === 'https://example.com' ? 'Example Page' : 'URL result' } }), { status: 200 });
}
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 });
})
}));
Comment on lines +7 to +15

vi.mock('@/app/api/lookup/doi/route', () => ({
POST: vi.fn(async (req) => {
const data = await req.json();
if (data.doi === '10.1000/xyz123' || data.doi === '10.1234/test') {
return new Response(JSON.stringify({ data: { title: 'DOI result' } }), { status: 200 });
}
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 });
})
}));
Comment on lines +17 to +25

vi.mock('@/app/api/lookup/isbn/route', () => ({
POST: vi.fn(async (req) => {
const data = await req.json();
if (data.isbn === '9780316769174') {
return new Response(JSON.stringify({ data: { title: 'ISBN result' } }), { status: 200 });
}
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 });
})
}));
Comment on lines +27 to +35

function makeRequest(body: object) {
return new NextRequest('http://localhost/api/lookup/bulk', {
method: 'POST',
Expand Down Expand Up @@ -54,59 +84,36 @@ describe('Bulk Lookup API', () => {
});

it('routes URLs to /api/lookup/url', async () => {
(global.fetch 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');
});
Comment on lines 86 to 91

it('routes DOIs to /api/lookup/doi', async () => {
(global.fetch 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(data.results[0].data.title).toBe('DOI result');
});

it('routes ISBNs to /api/lookup/isbn', async () => {
(global.fetch 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(data.results[0].data.title).toBe('ISBN result');
});

it('marks item as failed when sub-request fails', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Not found' }),
});
const response = await POST(makeRequest({ items: ['10.1000/nonexistent'] }));
const data = await response.json();
expect(data.results[0].success).toBe(false);
expect(data.results[0].error).toBe('Not found');
});

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' }) });
const response = await POST(
makeRequest({ items: ['https://success.com', '10.1000/fail'] })
makeRequest({ items: ['https://example.com', '10.1000/nonexistent'] })
);
const data = await response.json();
expect(data.summary.total).toBe(2);
Expand All @@ -115,11 +122,8 @@ 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' } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'DOI result' } }) });
const response = await POST(
makeRequest({ items: ['https://example.com', '10.1000/abc'] })
makeRequest({ items: ['https://url-test.com', '10.1234/test'] })
);
const data = await response.json();
expect(data.summary.success).toBe(2);
Expand Down
21 changes: 14 additions & 7 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 lookupUrlPost } from "@/app/api/lookup/url/route";
import { POST as lookupDoiPost } from "@/app/api/lookup/doi/route";
import { POST as lookupIsbnPost } from "@/app/api/lookup/isbn/route";

interface LookupResult {
input: string;
Expand All @@ -22,7 +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();
Expand All @@ -31,19 +33,23 @@ export async function POST(request: NextRequest) {
}

try {
let apiEndpoint: string;
let routeHandler: (req: NextRequest) => Promise<Response>;
let body: object;
let routeUrl: string;

// Detect input type
if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
apiEndpoint = "/api/lookup/url";
routeHandler = lookupUrlPost;
body = { url: trimmedItem };
routeUrl = "http://localhost/api/lookup/url";
} else if (trimmedItem.match(/^10\.\d{4,}/)) {
apiEndpoint = "/api/lookup/doi";
routeHandler = lookupDoiPost;
body = { doi: trimmedItem };
routeUrl = "http://localhost/api/lookup/doi";
} else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) {
apiEndpoint = "/api/lookup/isbn";
routeHandler = lookupIsbnPost;
body = { isbn: trimmedItem };
routeUrl = "http://localhost/api/lookup/isbn";
Comment on lines +42 to +52
} else {
return {
input: trimmedItem,
Expand All @@ -52,13 +58,14 @@ export async function POST(request: NextRequest) {
};
}

// Make the API call
const response = await fetch(`${baseUrl}${apiEndpoint}`, {
// Invoke the route handler directly to prevent SSRF
const mockRequest = new NextRequest(routeUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});

const response = await routeHandler(mockRequest);
Comment on lines +61 to +68
const data = await response.json();

if (response.ok && data.data) {
Expand Down