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-03-08 - Fixed SSRF in bulk lookup API

**Vulnerability:** Server-Side Request Forgery (SSRF) risk in `src/app/api/lookup/bulk/route.ts` where it was using `fetch(\`\${request.nextUrl.origin}/api/lookup/...\`)`. `request.nextUrl.origin` is dynamically derived from the potentially attacker-controllable `Host` header.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Fix malformed code span (breaks rendering).

Backslash doesn't escape backticks inside a single-backtick code span per CommonMark, so this span likely terminates early and renders with stray backticks/backslashes.

πŸ“ Proposed fix using a double-backtick fence
-**Vulnerability:** Server-Side Request Forgery (SSRF) risk in `src/app/api/lookup/bulk/route.ts` where it was using `fetch(\`\${request.nextUrl.origin}/api/lookup/...\`)`. `request.nextUrl.origin` is dynamically derived from the potentially attacker-controllable `Host` header.
+**Vulnerability:** Server-Side Request Forgery (SSRF) risk in `src/app/api/lookup/bulk/route.ts` where it was using ``fetch(`${request.nextUrl.origin}/api/lookup/...`)``. `request.nextUrl.origin` is dynamically derived from the potentially attacker-controllable `Host` header.

Flagged by markdownlint-cli2: "Spaces inside code span elements (MD038, no-space-in-code)".

πŸ“ 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
**Vulnerability:** Server-Side Request Forgery (SSRF) risk in `src/app/api/lookup/bulk/route.ts` where it was using `fetch(\`\${request.nextUrl.origin}/api/lookup/...\`)`. `request.nextUrl.origin` is dynamically derived from the potentially attacker-controllable `Host` header.
**Vulnerability:** Server-Side Request Forgery (SSRF) risk in `src/app/api/lookup/bulk/route.ts` where it was using ``fetch(`${request.nextUrl.origin}/api/lookup/...`)``. `request.nextUrl.origin` is dynamically derived from the potentially attacker-controllable `Host` header.
🧰 Tools
πŸͺ› markdownlint-cli2 (0.22.1)

[warning] 11-11: Spaces inside code span elements

(MD038, no-space-in-code)

πŸ€– 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 @.jules/sentinel.md at line 11, The markdown in this sentinel note has a
malformed inline code span because backslashes do not escape backticks inside
single-backtick code spans, which breaks rendering. Update the affected sentence
in .jules/sentinel.md to use a valid inline code span format (for example, a
double-backtick fence or reworded code formatting) so the literal
`fetch(...)`/`request.nextUrl.origin` snippet renders correctly and complies
with markdownlint-cli2 MD038.

Source: Linters/SAST tools

**Learning:** Using loopback fetches to internal APIs based on request headers is a common SSRF vector. Next.js App Router exposes the route handler functions, so we can directly invoke them rather than doing loopback fetches.
**Prevention:** Always directly import and invoke internal Next.js Route Handler functions instead of performing loopback HTTP `fetch` requests with dynamically derived hostnames. Pass a synthetic `NextRequest` with only safe, required headers if invoking handlers directly.
35 changes: 19 additions & 16 deletions src/app/api/lookup/bulk/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
import { POST } from './route';
import { NextRequest } from 'next/server';

vi.mock('../url/route', () => ({ POST: vi.fn() }));
vi.mock('../doi/route', () => ({ POST: vi.fn() }));
vi.mock('../isbn/route', () => ({ POST: vi.fn() }));

import { POST as urlPOST } from '../url/route';
import { POST as doiPOST } from '../doi/route';
import { POST as isbnPOST } from '../isbn/route';

global.fetch = vi.fn();

function makeRequest(body: object) {
Expand Down Expand Up @@ -54,44 +62,41 @@
});

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

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

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

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

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

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

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

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

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' } }) });
(urlPOST as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, json: async () => ({ data: { title: 'URL result' } }) });
(doiPOST 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
34 changes: 21 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 urlPOST } from "../url/route";
import { POST as doiPOST } from "../doi/route";
import { POST as isbnPOST } from "../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 body: object;
let handler: (req: NextRequest) => Promise<NextResponse>;
let reqBody: object;
let endpointName: string;

// Detect input type
if (trimmedItem.match(/^(https?:\/\/|www\.)/i)) {
apiEndpoint = "/api/lookup/url";
body = { url: trimmedItem };
handler = urlPOST as (req: NextRequest) => Promise<NextResponse>;
endpointName = "url";
reqBody = { url: trimmedItem };
} else if (trimmedItem.match(/^10\.\d{4,}/)) {
apiEndpoint = "/api/lookup/doi";
body = { doi: trimmedItem };
handler = doiPOST as (req: NextRequest) => Promise<NextResponse>;
endpointName = "doi";
reqBody = { doi: trimmedItem };
} else if (trimmedItem.match(/^(97[89])?\d{9}[\dXx]$/)) {
apiEndpoint = "/api/lookup/isbn";
body = { isbn: trimmedItem };
handler = isbnPOST as (req: NextRequest) => Promise<NextResponse>;
endpointName = "isbn";
reqBody = { isbn: trimmedItem };
} else {
return {
input: trimmedItem,
Expand All @@ -52,13 +58,15 @@ export async function POST(request: NextRequest) {
};
}

// Make the API call
const response = await fetch(`${baseUrl}${apiEndpoint}`, {
const syntheticReq = new NextRequest(new URL(`http://localhost/api/lookup/${endpointName}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
headers: new Headers({ "Content-Type": "application/json" }),
body: JSON.stringify(reqBody)
});

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

const data = await response.json();

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