Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -2,3 +2,8 @@
**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.

## 2025-02-28 - [SSRF via Native Node.js fetch in URL Lookup]
**Vulnerability:** The `/api/lookup/url` endpoint takes a user-supplied URL and makes a server-side request using native `fetch()` without any validation of the target IP address. This allowed basic Server-Side Request Forgery (SSRF) where attackers could query internal networks (e.g. `http://127.0.0.1` or AWS metadata `http://169.254.169.254`).
**Learning:** Native `fetch()` does not automatically protect against local network requests. When performing pre-fetch DNS validation, modifying the URL directly to use an IP address can break TLS SNI.
**Prevention:** Always validate resolved IP addresses before executing requests to untrusted URLs. Added an `isPrivateIP` check after doing a DNS lookup. Advanced prevention for full DNS rebinding with SNI support would require custom HTTP dispatchers (like `undici`), but basic pre-fetch DNS lookup provides a strong first line of defense.
58 changes: 58 additions & 0 deletions src/app/api/lookup/url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

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

interface MetadataResult {
title?: string;
Expand Down Expand Up @@ -54,6 +55,45 @@ interface APIResponse {
error?: string;
}


/**
* Check if an IP address is private/local.
*/
function isPrivateIP(ip: string): boolean {

// IPv6 unspecified address
if (ip === '::') return true;

// IPv6 loopback

if (ip === '::1') return true;

// IPv6 unique local address
if (ip.match(/^(fc|fd)/i)) return true;

// IPv6 link-local address
if (ip.match(/^fe80/i)) return true;

// IPv4 mapping
if (ip.startsWith('::ffff:')) {
ip = ip.replace('::ffff:', '');
}

const parts = ip.split('.');
if (parts.length !== 4) return false;

const numParts = parts.map(Number);

return (
numParts[0] === 10 || // 10.0.0.0/8
(numParts[0] === 172 && numParts[1] >= 16 && numParts[1] <= 31) || // 172.16.0.0/12
(numParts[0] === 192 && numParts[1] === 168) || // 192.168.0.0/16
numParts[0] === 127 || // 127.0.0.0/8
numParts[0] === 0 || // 0.0.0.0/8
(numParts[0] === 169 && numParts[1] === 254) // 169.254.0.0/16
);
}

export async function POST(request: NextRequest): Promise<NextResponse<APIResponse>> {
try {
const body = await request.json();
Expand All @@ -77,9 +117,27 @@ export async function POST(request: NextRequest): Promise<NextResponse<APIRespon
);
}


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

// SSRF Protection: Pre-fetch DNS validation
try {
const { address } = await lookup(parsedUrl.hostname);
Comment on lines +124 to +126
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

For SSRF hardening, explicitly allow only http:/https: URLs (reject other schemes with a clear 400) before running DNS resolution. This avoids cases like file:/empty hostname falling into the generic "Failed to resolve hostname" path.

Copilot uses AI. Check for mistakes.
if (isPrivateIP(address)) {
Comment on lines +126 to +127
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

lookup(parsedUrl.hostname) only validates a single resolved address; hostnames with multiple A/AAAA records can still resolve to a private/internal IP on a different record. Consider resolving with { all: true } (and ideally verbatim: true) and rejecting if any returned address is private.

Suggested change
const { address } = await lookup(parsedUrl.hostname);
if (isPrivateIP(address)) {
const addresses = await lookup(parsedUrl.hostname, { all: true, verbatim: true });
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateIP(address))) {

Copilot uses AI. Check for mistakes.
return NextResponse.json(
Comment on lines +124 to +128
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

This pre-fetch DNS check can be bypassed via redirects (fetch follows redirects by default) and DNS rebinding (fetch may resolve again later). To make the SSRF mitigation effective, disable automatic redirects and re-validate each Location hop (or use a client/dispatcher that pins the connection to the validated IP while preserving SNI/Host).

Copilot uses AI. Check for mistakes.
{ success: false, error: 'Access to internal networks is not allowed' },
Comment on lines +125 to +129
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

Adding dns/promises lookup makes this handler (and existing unit tests) depend on real DNS/network conditions. Please mock dns/promises in unit tests and add coverage for the new SSRF behavior (e.g., 127.0.0.1/::1 rejected with 403, and lookup failures returning 400).

Copilot uses AI. Check for mistakes.
{ status: 403 }
);
}
} catch {
return NextResponse.json(
{ success: false, error: 'Failed to resolve hostname' },
{ status: 400 }
);
}


// For arXiv URLs, short-circuit to the arXiv API which gives richer metadata
if (detection.sourceType === 'preprint' && detection.arxivId) {
const arxivData = await fetchArxivMetadata(detection.arxivId);
Expand Down
Loading