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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@
**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-06-29 - Fix SSRF in bulk lookup API
**Vulnerability:** The bulk lookup API (`/api/lookup/bulk`) used `fetch()` with dynamically derived hostnames (`request.nextUrl.origin`) for loopback requests to internal route handlers. This is an SSRF vulnerability because `request.nextUrl.origin` is constructed using the user-controllable `Host` header.
**Learning:** Using `request.nextUrl.origin` inside internal `fetch` requests can allow attackers to spoof the `Host` header and redirect internal server requests to arbitrary domains.
**Prevention:** Call Next.js API route handlers directly as imported functions with synthetic `NextRequest` objects instead of performing HTTP loopback requests.
55 changes: 34 additions & 21 deletions src/app/api/lookup/bulk/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from './route';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';

Check failure on line 3 in src/app/api/lookup/bulk/route.test.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test, build

'NextResponse' is defined but never used. Allowed unused vars must match /^_/u
import { POST as urlHandler } from '../url/route';
import { POST as doiHandler } from '../doi/route';
import { POST as isbnHandler } from '../isbn/route';

vi.mock('../url/route', () => ({ POST: vi.fn() }));
vi.mock('../doi/route', () => ({ POST: vi.fn() }));
vi.mock('../isbn/route', () => ({ POST: vi.fn() }));
Comment on lines +3 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

Assert the forwarded JSON body in these handler mocks.

Right now these stubs succeed for any request, so a regression from { url } / { doi } / { isbn } to the wrong payload shape would still pass even though the real sub-routes reject it. Switching these to mockImplementationOnce and checking await req.json() locks the test to the actual cross-route contract, and it also gives NextResponse at Line 3 a real use.

Proposed test pattern
-    (urlHandler as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
-      ok: true,
-      json: async () => ({ data: { title: 'Example Page' } }),
-    });
+    (urlHandler as ReturnType<typeof vi.fn>).mockImplementationOnce(async (req: NextRequest) => {
+      expect(await req.json()).toEqual({ url: 'https://example.com' });
+      return NextResponse.json({ data: { title: 'Example Page' } });
+    });

As per coding guidelines, "Write tests for citation formatting and API route logic."

Also applies to: 62-97, 108-115, 126-133

🧰 Tools
πŸͺ› ESLint

[error] 3-3: 'NextResponse' is defined but never used. Allowed unused vars must match /^_/u.

(@typescript-eslint/no-unused-vars)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/lookup/bulk/route.test.ts` around lines 3 - 10, The mocks for the
URL/DOI/ISBN handlers are too permissive and don’t verify the forwarded request
body shape, so regressions in the bulk route contract can slip through. Update
the tests around bulk lookup to use the mocked POST handlers from urlHandler,
doiHandler, and isbnHandler with mockImplementationOnce and inspect await
req.json() inside each mock to assert the expected payload keys ({ url }, { doi
}, { isbn }). Keep the assertions tied to the bulk route’s forwarding behavior
so the tests fail if the wrong JSON shape is sent.

Sources: Coding guidelines, Linters/SAST tools


global.fetch = vi.fn();

function makeRequest(body: object) {
return new NextRequest('http://localhost/api/lookup/bulk', {
Expand Down Expand Up @@ -53,45 +59,42 @@
expect(data.results[0].error).toMatch(/Unrecognized/i);
});

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

Check warning on line 65 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(urlHandler).toHaveBeenCalled();
});

it('routes DOIs to /api/lookup/doi', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
it('routes DOIs to doiHandler', async () => {
(doiHandler as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Research Article' } }),

Check warning on line 77 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(doiHandler).toHaveBeenCalled();
});

it('routes ISBNs to /api/lookup/isbn', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
it('routes ISBNs to isbnHandler', async () => {
(isbnHandler as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Book Title' } }),

Check warning on line 88 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(isbnHandler).toHaveBeenCalled();
});

it('marks item as failed when sub-request fails', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
(doiHandler as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Not found' }),
});
Expand All @@ -102,9 +105,14 @@
});

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' }) });
(urlHandler as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'A' } }),
});
(doiHandler 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 +123,14 @@
});

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' } }) });
(urlHandler as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'URL result' } }),
});
(doiHandler 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
31 changes: 17 additions & 14 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 urlHandler } from "../url/route";
import { POST as doiHandler } from "../doi/route";
import { POST as isbnHandler } from "../isbn/route";

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

try {
let apiEndpoint: string;
let body: object;
let handler: typeof urlHandler;
let handlerBody: object;

// Detect input type
if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
apiEndpoint = "/api/lookup/url";
body = { url: trimmedItem };
handler = urlHandler;
handlerBody = { url: trimmedItem };
} else if (trimmedItem.match(/^10\.\d{4,}/)) {
apiEndpoint = "/api/lookup/doi";
body = { doi: trimmedItem };
handler = doiHandler;
handlerBody = { doi: trimmedItem };
} else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) {
apiEndpoint = "/api/lookup/isbn";
body = { isbn: trimmedItem };
handler = isbnHandler;
handlerBody = { isbn: trimmedItem };
} else {
return {
input: trimmedItem,
Expand All @@ -52,13 +55,13 @@ export async function POST(request: NextRequest) {
};
}

// Make the API call
const response = await fetch(`${baseUrl}${apiEndpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
// Direct function call to prevent SSRF
const req = new NextRequest(new URL('http://localhost'), {
method: 'POST',
body: JSON.stringify(handlerBody),
});

const response = await handler(req);
const data = await response.json();

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