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-02-28 - Next.js SSRF via Loopback fetch()
**Vulnerability:** The API endpoint (`/api/lookup/bulk/route.ts`) performed a loopback fetch request internally using `request.nextUrl.origin` to derive the base URL. This allowed attackers to perform Server-Side Request Forgery (SSRF) because the user-controllable `Host` header could be manipulated to redirect requests to arbitrary internal services.
**Learning:** In Next.js, relying on `request.nextUrl.origin` (or `request.headers.get("host")`) for internal service calls is dangerous since it is derived from the client's `Host` header.
**Prevention:** Rather than performing network requests with `fetch()`, internal Route Handlers should be directly imported and invoked with a securely constructed, synthetic `NextRequest` object (e.g. `new NextRequest(new URL('http://localhost'), { method: 'POST', body: ... })`).
71 changes: 41 additions & 30 deletions src/app/api/lookup/bulk/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from './route';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
Comment on lines 1 to +3

global.fetch = vi.fn();
vi.mock('@/app/api/lookup/url/route', () => ({
POST: vi.fn(),
}));
vi.mock('@/app/api/lookup/doi/route', () => ({
POST: vi.fn(),
}));
vi.mock('@/app/api/lookup/isbn/route', () => ({
POST: vi.fn(),
}));

import { POST as lookupUrl } from '@/app/api/lookup/url/route';
import { POST as lookupDoi } from '@/app/api/lookup/doi/route';
import { POST as lookupIsbn } from '@/app/api/lookup/isbn/route';

function makeRequest(body: object) {
return new NextRequest('http://localhost/api/lookup/bulk', {
Expand Down Expand Up @@ -54,57 +66,53 @@ 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' } }),
});
(lookupUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ data: { title: 'Example Page' } }, { status: 200 })
);
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(lookupUrl).toHaveBeenCalled();
});
Comment on lines 68 to 77

it('routes DOIs to /api/lookup/doi', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Research Article' } }),
});
(lookupDoi as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ data: { title: 'Research Article' } }, { status: 200 })
);
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(lookupDoi).toHaveBeenCalled();
});

it('routes ISBNs to /api/lookup/isbn', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { title: 'Book Title' } }),
});
(lookupIsbn as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ data: { title: 'Book Title' } }, { status: 200 })
);
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(lookupIsbn).toHaveBeenCalled();
});

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' }),
});
(lookupDoi as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ error: 'Not found' }, { status: 404 })
);
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' }) });
(lookupUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ data: { title: 'A' } }, { status: 200 })
);
(lookupDoi as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ error: 'fail' }, { status: 404 })
);
const response = await POST(
makeRequest({ items: ['https://success.com', '10.1000/fail'] })
);
Expand All @@ -115,9 +123,12 @@ 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' } }) });
(lookupUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ data: { title: 'URL result' } }, { status: 200 })
);
(lookupDoi as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
NextResponse.json({ data: { title: 'DOI result' } }, { status: 200 })
);
const response = await POST(
makeRequest({ items: ['https://example.com', '10.1000/abc'] })
);
Expand Down
36 changes: 21 additions & 15 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 "@/app/api/lookup/url/route";
import { POST as lookupDoi } from "@/app/api/lookup/doi/route";
import { POST as lookupIsbn } from "@/app/api/lookup/isbn/route";

interface LookupResult {
input: string;
Expand All @@ -21,29 +24,27 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Maximum 20 items allowed per request" }, { status: 400 });
}

// Refactored to process lookups concurrently for performance improvement
const baseUrl = request.nextUrl.origin;

// Process lookups concurrently using direct internal handler invocation
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 handler: (req: NextRequest) => Promise<NextResponse> | NextResponse;
let payload: object;

// Detect input type
if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
apiEndpoint = "/api/lookup/url";
body = { url: trimmedItem };
handler = lookupUrl as unknown as (req: NextRequest) => Promise<NextResponse> | NextResponse;
payload = { url: trimmedItem };
Comment on lines 39 to +41

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== route.ts files ==\n'
git ls-files 'src/app/api/lookup/**/route.ts'

printf '\n== bulk route excerpt ==\n'
sed -n '1,140p' src/app/api/lookup/bulk/route.ts

printf '\n== url route excerpt ==\n'
sed -n '1,160p' src/app/api/lookup/url/route.ts

printf '\n== search for URL normalization ==\n'
rg -n "new URL\\(|https://|www\\.|normalize.*url|scheme" src/app/api/lookup -S

Repository: aicoder2009/opencitation

Length of output: 14414


Normalize www. URLs before delegating in src/app/api/lookup/bulk/route.ts:39-41. lookup/url calls new URL(url), so inputs like www.example.com are rejected as Invalid URL format. Prepend https:// when the scheme is missing.

🐛 Proposed fix
         if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
           handler = lookupUrl as unknown as (req: NextRequest) => Promise<NextResponse> | NextResponse;
-          payload = { url: trimmedItem };
+          payload = { url: /^www\./i.test(trimmedItem) ? `https://${trimmedItem}` : trimmedItem };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
apiEndpoint = "/api/lookup/url";
body = { url: trimmedItem };
handler = lookupUrl as unknown as (req: NextRequest) => Promise<NextResponse> | NextResponse;
payload = { url: trimmedItem };
if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
handler = lookupUrl as unknown as (req: NextRequest) => Promise<NextResponse> | NextResponse;
payload = { url: /^www\./i.test(trimmedItem) ? `https://${trimmedItem}` : trimmedItem };
🤖 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.ts` around lines 39 - 41, Normalize bare www.
inputs before calling lookupUrl in bulk route handling: the trimmedItem branch
in the bulk lookup route currently forwards values like www.example.com
unchanged, which later fails in lookup/url when new URL(url) is parsed. Update
the payload construction in the bulk route’s URL-detection path so that when the
input starts with www. but has no scheme, it is prefixed with https:// before
delegating to lookupUrl; keep already-schemed URLs unchanged.

} else if (trimmedItem.match(/^10\.\d{4,}/)) {
apiEndpoint = "/api/lookup/doi";
body = { doi: trimmedItem };
handler = lookupDoi as unknown as (req: NextRequest) => Promise<NextResponse> | NextResponse;
Comment on lines 39 to +43
payload = { doi: trimmedItem };
} else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) {
apiEndpoint = "/api/lookup/isbn";
body = { isbn: trimmedItem };
handler = lookupIsbn as unknown as (req: NextRequest) => Promise<NextResponse> | NextResponse;
payload = { isbn: trimmedItem };
Comment on lines 45 to +47

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the bulk lookup route and ISBN handler files
git ls-files 'src/app/api/lookup/**' 'src/app/api/**/isbn/**' 'src/lib/**' | sed -n '1,200p'

echo '--- bulk route outline ---'
ast-grep outline src/app/api/lookup/bulk/route.ts --view expanded || true

echo '--- search for cleanISBN and ISBN-related validation ---'
rg -n "cleanISBN|isbn|Unrecognized format|lookupIsbn" src/app/api src/lib -g '!**/*.map'

echo '--- read relevant sections ---'
for f in \
  src/app/api/lookup/bulk/route.ts \
  src/app/api/lookup/isbn/route.ts \
  src/lib/*isbn* \
  src/lib/*ISBN*; do
  [ -f "$f" ] && { echo "### $f"; wc -l "$f"; cat -n "$f" | sed -n '1,220p'; }
done

Repository: aicoder2009/opencitation

Length of output: 21586


Normalize ISBNs before bulk matching src/app/api/lookup/bulk/route.ts:45-47trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/) rejects hyphenated/spaced ISBNs that /api/lookup/isbn accepts after cleanISBN(), so pasted ISBNs fall into “Unrecognized format” in bulk. Strip separators before this check.

🤖 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.ts` around lines 45 - 47, Bulk ISBN matching is
using trimmedItem directly, so hyphenated or spaced ISBNs are rejected even
though lookupIsbn accepts normalized values. In
src/app/api/lookup/bulk/route.ts, normalize the candidate ISBN with the same
cleanISBN-style separator stripping used by /api/lookup/isbn before the
trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/) check, and use that normalized value
for the isbn payload passed to lookupIsbn.

} else {
return {
input: trimmedItem,
Expand All @@ -52,13 +53,18 @@ export async function POST(request: NextRequest) {
};
}

// Make the API call
const response = await fetch(`${baseUrl}${apiEndpoint}`, {
// Create a synthetic NextRequest to avoid SSRF
const reqUrl = new URL("http://localhost");
const syntheticReq = new NextRequest(reqUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify(payload),
});

// Make the API call directly
const response = await handler(syntheticReq);
const data = await response.json();

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