Skip to content

🛡️ Sentinel: [CRITICAL] Fix loopback fetch SSRF in bulk lookup API#148

Open
aicoder2009 wants to merge 1 commit into
mainfrom
sentinel-fix-ssrf-bulk-lookup-4228218702171621260
Open

🛡️ Sentinel: [CRITICAL] Fix loopback fetch SSRF in bulk lookup API#148
aicoder2009 wants to merge 1 commit into
mainfrom
sentinel-fix-ssrf-bulk-lookup-4228218702171621260

Conversation

@aicoder2009

@aicoder2009 aicoder2009 commented Jun 6, 2026

Copy link
Copy Markdown
Owner

🛡️ Sentinel Security Fix

Vulnerability:
The bulk lookup API (src/app/api/lookup/bulk/route.ts) was using a native fetch() loopback call to reach internal lookup services (url, doi, isbn). It dynamically constructed the base URL for this fetch by reading request.nextUrl.origin. Because Next.js derives the origin from user-controllable request headers (such as Host or X-Forwarded-Host), an attacker could supply a spoofed Host header and force the backend server to proxy the request to an attacker-controlled endpoint.

Severity: CRITICAL

Fix:
Removed the vulnerable network loopback entirely. The API now safely and idiomatically directly imports the target POST handlers (urlLookup, doiLookup, isbnLookup) and invokes them as functions, passing a securely constructed synthetic NextRequest to satisfy their type signatures. The test suite was also fully updated to replace global.fetch mocks with vi.mock for the imported handlers.

Verification:

  • Ran pnpm test:run - All 652 tests pass successfully.
  • Code review approved.

PR created automatically by Jules for task 4228218702171621260 started by @aicoder2009

Summary by CodeRabbit

Bug Fixes

  • Fixed a Server-Side Request Forgery (SSRF) vulnerability in the bulk lookup API that could be exploited through spoofed request headers.

Co-authored-by: aicoder2009 <127642633+aicoder2009@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@vercel

vercel Bot commented Jun 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
opencitation Ready Ready Preview, Comment Jun 6, 2026 6:48am

Copilot AI review requested due to automatic review settings June 6, 2026 06:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Too many files changed? Review this PR in Change Stack to see how the pieces fit before you dive in.

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes an SSRF vulnerability in the bulk lookup API by eliminating host-header-based fetch calls. The endpoint now invokes internal route handlers directly with a fixed localhost origin instead of constructing URLs from request-derived headers. Tests are updated to mock the sub-route handlers instead of global fetch calls.

Changes

SSRF Vulnerability Fix and Migration

Layer / File(s) Summary
Vulnerability documentation
.jules/sentinel.md
Sentinel entry documents the SSRF risk where request.nextUrl.origin could be influenced by spoofed Host/X-Forwarded-Host headers, and prescribes direct handler invocation with safe origins as mitigation.
Bulk route handler invocation refactoring
src/app/api/lookup/bulk/route.ts
Imports POST handlers from URL/DOI/ISBN sub-routes and refactors each lookup to create a synthetic NextRequest with localhost origin and invoke the selected handler directly, replacing the prior fetch-based approach. Input type detection and handler selection logic remain unchanged.
Test migration to sub-route handler mocks
src/app/api/lookup/bulk/route.test.ts
Updates test imports to include NextResponse and sub-route handler POST functions, replaces global.fetch mocks with vi.mock stubs for each sub-route, and rewrites all test cases (routing, error handling, summary counts, mixed batches) to verify the bulk handler calls the correct sub-route mock.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A header spoofed? Not on my watch!
From risky fetches, we now dispatch—
Direct to handlers, localhost true,
No Origin tricks can sneak on through!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly identifies the main change: fixing a critical SSRF vulnerability in the bulk lookup API's loopback fetch implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sentinel-fix-ssrf-bulk-lookup-4228218702171621260

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/app/api/lookup/bulk/route.test.ts`:
- Around line 61-70: Tests currently miss a regression for Host/X-Forwarded-Host
spoofing; add a new test in route.test.ts that uses the existing POST and
makeRequest helpers to send a request with malicious Host and X-Forwarded-Host
headers and then assert urlLookup was called with a Request/synthetic request
whose URL origin is http://localhost (not the spoofed header). Locate the test
helpers used in the file (POST, makeRequest) and the mocked handler urlLookup
and add an assertion that the argument passed to urlLookup contains a URL
beginning with "http://localhost" (use the same pattern of checking the called
argument via vi/expect to verify the synthetic request origin).

In `@src/app/api/lookup/bulk/route.ts`:
- Around line 28-31: The code calls item.trim() inside the items.map callback
(creating lookupPromises) before any per-item validation, so a non-string entry
will throw and cause Promise.all to reject; update the lookupPromises mapping
(the async callback used to build lookupPromises) to validate the item type
(e.g., ensure typeof item === "string") before calling trim or move the trim
inside the existing per-item try/catch, and for non-string values return the
per-item failure object ({ input: item, success: false, error: "Invalid input
type" }) so malformed entries fail individually instead of triggering a
batch-wide 500.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: aa4e142e-e53d-47d0-a77b-92af4aa5f258

📥 Commits

Reviewing files that changed from the base of the PR and between b872925 and f8f9a2e.

📒 Files selected for processing (3)
  • .jules/sentinel.md
  • src/app/api/lookup/bulk/route.test.ts
  • src/app/api/lookup/bulk/route.ts

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a Host-header spoof regression test for this SSRF fix.

Current tests validate routing/mocks, but they don’t assert behavior when inbound Host / X-Forwarded-Host is attacker-controlled. Add one test that sends spoofed headers and verifies the called synthetic request still uses http://localhost.

As per coding guidelines, src/app/api/**/*.ts should include tests for API route logic.

🤖 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 61 - 70, Tests currently
miss a regression for Host/X-Forwarded-Host spoofing; add a new test in
route.test.ts that uses the existing POST and makeRequest helpers to send a
request with malicious Host and X-Forwarded-Host headers and then assert
urlLookup was called with a Request/synthetic request whose URL origin is
http://localhost (not the spoofed header). Locate the test helpers used in the
file (POST, makeRequest) and the mocked handler urlLookup and add an assertion
that the argument passed to urlLookup contains a URL beginning with
"http://localhost" (use the same pattern of checking the called argument via
vi/expect to verify the synthetic request origin).

Source: Coding guidelines

Comment on lines 28 to 31
const lookupPromises = items.map(async (item) => {
const trimmedItem = item.trim();
if (!trimmedItem) {
return { input: item, success: false, error: "Empty input" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate each items entry before calling .trim() to avoid batch-wide 500s.

At Line 29, item.trim() runs before the inner try block. Since items is only compile-time-cast, a non-string value (e.g., null, number) throws and causes Promise.all to reject, returning a top-level 500 instead of a per-item failure.

Suggested fix
-    const lookupPromises = items.map(async (item) => {
-      const trimmedItem = item.trim();
+    const lookupPromises = items.map(async (item) => {
+      if (typeof item !== "string") {
+        return {
+          input: String(item),
+          success: false,
+          error: "Each item must be a string",
+        };
+      }
+
+      const trimmedItem = item.trim();
📝 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
const lookupPromises = items.map(async (item) => {
const trimmedItem = item.trim();
if (!trimmedItem) {
return { input: item, success: false, error: "Empty input" };
const lookupPromises = items.map(async (item) => {
if (typeof item !== "string") {
return {
input: String(item),
success: false,
error: "Each item must be a string",
};
}
const trimmedItem = item.trim();
if (!trimmedItem) {
return { input: item, success: false, error: "Empty input" };
🤖 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 28 - 31, The code calls
item.trim() inside the items.map callback (creating lookupPromises) before any
per-item validation, so a non-string entry will throw and cause Promise.all to
reject; update the lookupPromises mapping (the async callback used to build
lookupPromises) to validate the item type (e.g., ensure typeof item ===
"string") before calling trim or move the trim inside the existing per-item
try/catch, and for non-string values return the per-item failure object ({
input: item, success: false, error: "Invalid input type" }) so malformed entries
fail individually instead of triggering a batch-wide 500.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants