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 @@ -2,3 +2,7 @@
**Vulnerability:** The application was passing unvalidated HTML variables, specifically `citation.formattedHtml`, to React's `dangerouslySetInnerHTML` prop in multiple components (`src/components/wiki/sortable-citation.tsx`, `src/app/cite/page.tsx`, `src/app/share/[code]/page.tsx`).
**Learning:** This is a classic pattern for Cross-Site Scripting (XSS). If a citation's contents originated from an untrusted source or were maliciously formatted, an attacker could execute arbitrary scripts in a user's session when the citation is rendered.
**Prevention:** Always sanitize any untrusted or dynamic HTML before rendering it in React. In a Next.js (SSR) application, use a library like `isomorphic-dompurify` to safely strip malicious scripts from the HTML payload on both the client and server side without hydration errors.
## 2026-04-26 - [SSRF Protection in Next.js Fetch API]
**Vulnerability:** Found a Server-Side Request Forgery (SSRF) vulnerability in `src/app/api/lookup/url/route.ts`. The endpoint accepts user-provided URLs and calls `fetch()` directly on them without validating the underlying resolved IP address, exposing the app to internal network scanning and cloud metadata access.
**Learning:** When mitigating SSRF via Node.js native `fetch`, directly rewriting the URL to use a resolved IP address to prevent DNS rebinding breaks Server Name Indication (SNI) and TLS validation for HTTPS requests.
**Prevention:** Full DNS rebinding protection while maintaining SNI requires custom HTTP dispatchers/agents like `undici`. Without such libraries, a standard and robust first-line defense is to resolve the URL hostname to an IP and filter out private IPv4/IPv6 networks prior to executing the `fetch()` request, recognizing it leaves a Time-Of-Check to Time-Of-Use (TOCTOU) gap.
48 changes: 48 additions & 0 deletions src/app/api/lookup/url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { NextRequest, NextResponse } from 'next/server';
import { lookup } from 'dns/promises';
import type { SourceType } from '@/types';

interface MetadataResult {
Expand Down Expand Up @@ -77,6 +78,53 @@ export async function POST(request: NextRequest): Promise<NextResponse<APIRespon
);
}


// SSRF Protection: Prevent requests to internal networks
try {
let hostname = parsedUrl.hostname;

// Strip IPv6 brackets for lookup
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}

if (hostname === 'localhost') {
throw new Error('Forbidden IP');
}

// Try to resolve the hostname. If it's an IP literal, lookup returns it.
const { address } = await lookup(hostname);

Comment on lines +95 to +97
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

lookup(hostname) returns a single address, but many hostnames resolve to multiple A/AAAA records; fetch() may connect to a different record than the one you validated (even without DNS rebinding). Use lookup(hostname, { all: true }) (and ideally reject if any resolved address is non-public), or otherwise ensure the validated address is the one actually used for the outbound connection.

Copilot uses AI. Check for mistakes.
// Comprehensive regex for private IPv4, IPv6 loopback, IPv6 unique local, IPv6 link local, etc.
const privateIPRegex = /^(::f{4}:)?(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3}|0\.0\.0\.0)$/i;

// Check IPv4 private and basic IPv6 local
const isPrivateIPv4 = privateIPRegex.test(address);
const isLocalIPv6 = address === '::1' || address === '::' || address.toLowerCase().startsWith('fe80:') || address.toLowerCase().match(/^f[cd][0-9a-f]{2}:/i);
Comment on lines +98 to +103
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

The private IP allowlist/denylist is incomplete for common non-public IPv4 ranges (e.g., CGNAT 100.64.0.0/10, 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15). As written, requests to services bound in those ranges would still be permitted. Consider expanding the checks to cover all RFC6890 special-purpose ranges (or switch to an IP parsing/range library to avoid regex gaps).

Copilot uses AI. Check for mistakes.

if (isPrivateIPv4 || isLocalIPv6) {
return NextResponse.json(
Comment on lines +82 to +106
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

This new SSRF protection path isn’t covered by the existing route.test.ts tests for this handler, and it introduces an unmocked DNS call (dns/promises.lookup) that can make unit tests non-deterministic. Add unit tests that assert 403 for localhost/private targets (IPv4 + IPv6) and mock lookup() so tests don’t depend on real DNS/network.

Copilot uses AI. Check for mistakes.
{ success: false, error: 'Forbidden: Cannot fetch from internal networks' },
{ status: 403 }
);
}
Comment on lines +82 to +110
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

SSRF check only validates the initial hostname/IP. Because fetch() follows redirects by default, an attacker can supply a public URL that 30x redirects to an internal/private address (e.g., metadata IP), bypassing this pre-check. Consider setting redirect: 'manual' and handling redirects explicitly with re-validation on each Location hop (or rejecting redirects entirely for this endpoint).

Copilot uses AI. Check for mistakes.
} catch (err: any) {
if (err.message === 'Forbidden IP') {
return NextResponse.json(
{ success: false, error: 'Forbidden: Cannot fetch from internal networks' },
{ status: 403 }
);
}
// If the domain doesn't resolve (ENOTFOUND), we still want to block if it was an IPv6 literal that failed lookup
if (err.code === 'ENOTFOUND' && parsedUrl.hostname.startsWith('[')) {
return NextResponse.json(
{ success: false, error: 'Forbidden: Cannot fetch from internal networks' },
{ status: 403 }
);
}
Comment on lines +118 to +124
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

The parsedUrl.hostname.startsWith('[') check is likely unreachable because URL.hostname is normalized and typically does not include IPv6 brackets (brackets are part of URL.host). If you want to detect IPv6 literals, consider using parsedUrl.host / parsedUrl.href parsing or net.isIP(parsedUrl.hostname) instead, and remove the dead branch to avoid a false sense of coverage.

Copilot uses AI. Check for mistakes.
// If it's a general lookup failure for a domain, let fetch handle it to give a natural error
}

// Detect the source type from the URL and grab any type-specific hints
const detection = detectSourceTypeFromUrl(parsedUrl);

Expand Down
Loading