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 unsanitized URL fetch]
**Vulnerability:** The application was making direct outgoing HTTP requests (`fetch`) based on user-supplied URLs without validating the resolved IP address in `/api/lookup/url/route.ts`.
**Learning:** This is a classic pattern for Server-Side Request Forgery (SSRF). An attacker could submit a URL pointing to internal or private IP addresses (like `127.0.0.1` or `169.254.169.254` AWS metadata service) causing the server to fetch and potentially expose internal service data to the attacker.
**Prevention:** Always validate the resolved IP address of any user-supplied URL prior to making backend requests. Abort the connection if the DNS resolution points to internal, loopback, or private network blocks. Note: Full DNS rebinding protection against native `fetch` requires dedicated client agents like `undici`.
7 changes: 7 additions & 0 deletions src/app/api/lookup/url/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { NextRequest } from 'next/server';
// Mock fetch globally
global.fetch = vi.fn();

// Mock dns globally to prevent SSRF block from failing tests
vi.mock('dns/promises', () => ({
default: {
lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
}
}));
Comment on lines +8 to +13
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

New DNS-based SSRF blocking behavior is introduced, but the tests only mock dns.lookup to always return a public IP and don't assert the new outcomes. Please add coverage for (1) blocking a private/loopback resolution (expect 403 and no fetch), and (2) DNS resolution failures (expect the intended error/status).

Copilot uses AI. Check for mistakes.

describe('URL Lookup API', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
39 changes: 39 additions & 0 deletions src/app/api/lookup/url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,30 @@
*/

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

/**
* Checks if an IP address is a private/internal IP.
* Helps prevent Server-Side Request Forgery (SSRF).
*/
function isPrivateIP(ip: string): boolean {
const parts = ip.split('.');
if (parts.length === 4) {
const [a, b] = parts.map(Number);
return (
a === 10 || // 10.0.0.0/8
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
(a === 192 && b === 168) || // 192.168.0.0/16
a === 127 || // 127.0.0.0/8
a === 0 || // 0.0.0.0/8
(a === 169 && b === 254) // 169.254.0.0/16
);
}
// Basic IPv6 check for localhost/private
return ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:');
}

Comment on lines 13 to +35
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

isPrivateIP doesn't catch IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1, ::ffff:169.254.169.254). A URL like http://[::ffff:127.0.0.1]/ would bypass the private-IP check and still allow SSRF. Consider normalizing IPv6 (including ::ffff: mapped addresses) and using a more robust IP-range check (e.g. net.isIP + parsing to bytes / an IP library) before deciding it's public.

Suggested change
import type { SourceType } from '@/types';
/**
* Checks if an IP address is a private/internal IP.
* Helps prevent Server-Side Request Forgery (SSRF).
*/
function isPrivateIP(ip: string): boolean {
const parts = ip.split('.');
if (parts.length === 4) {
const [a, b] = parts.map(Number);
return (
a === 10 || // 10.0.0.0/8
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
(a === 192 && b === 168) || // 192.168.0.0/16
a === 127 || // 127.0.0.0/8
a === 0 || // 0.0.0.0/8
(a === 169 && b === 254) // 169.254.0.0/16
);
}
// Basic IPv6 check for localhost/private
return ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc00:') || ip.startsWith('fd00:');
}
import net from 'net';
import type { SourceType } from '@/types';
/**
* Checks if an IP address is a private/internal IP.
* Helps prevent Server-Side Request Forgery (SSRF).
*/
function parseIPv4(ip: string): number[] | null {
const parts = ip.split('.');
if (parts.length !== 4) {
return null;
}
const bytes = parts.map((part) => Number(part));
if (bytes.some((byte, index) => !/^\d+$/.test(parts[index]) || byte < 0 || byte > 255)) {
return null;
}
return bytes;
}
function isPrivateIPv4(bytes: number[]): boolean {
const [a, b] = bytes;
return (
a === 10 || // 10.0.0.0/8
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
(a === 192 && b === 168) || // 192.168.0.0/16
a === 127 || // 127.0.0.0/8
a === 0 || // 0.0.0.0/8
(a === 169 && b === 254) // 169.254.0.0/16
);
}
function parseIPv6(ip: string): number[] | null {
let normalized = ip;
if (normalized.includes('%')) {
normalized = normalized.split('%')[0];
}
const halves = normalized.split('::');
if (halves.length > 2) {
return null;
}
const parseHextetGroup = (group: string): number[] | null => {
if (!group) {
return [];
}
const tokens = group.split(':');
const result: number[] = [];
for (const token of tokens) {
if (!token) {
return null;
}
if (token.includes('.')) {
const ipv4Bytes = parseIPv4(token);
if (!ipv4Bytes) {
return null;
}
result.push((ipv4Bytes[0] << 8) | ipv4Bytes[1], (ipv4Bytes[2] << 8) | ipv4Bytes[3]);
} else {
if (!/^[0-9a-fA-F]{1,4}$/.test(token)) {
return null;
}
result.push(parseInt(token, 16));
}
}
return result;
};
const left = parseHextetGroup(halves[0] ?? '');
const right = parseHextetGroup(halves[1] ?? '');
if (!left || !right) {
return null;
}
let hextets: number[];
if (halves.length === 2) {
const zerosToInsert = 8 - (left.length + right.length);
if (zerosToInsert < 1) {
return null;
}
hextets = [...left, ...new Array(zerosToInsert).fill(0), ...right];
} else {
if (left.length !== 8) {
return null;
}
hextets = left;
}
if (hextets.length !== 8) {
return null;
}
return hextets.flatMap((hextet) => [(hextet >> 8) & 0xff, hextet & 0xff]);
}
function isPrivateIPv6(bytes: number[]): boolean {
if (bytes.length !== 16) {
return false;
}
const isUnspecified = bytes.every((byte) => byte === 0);
if (isUnspecified) {
return true;
}
const isLoopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1;
if (isLoopback) {
return true;
}
const isLinkLocal = bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80; // fe80::/10
if (isLinkLocal) {
return true;
}
const isUniqueLocal = (bytes[0] & 0xfe) === 0xfc; // fc00::/7
if (isUniqueLocal) {
return true;
}
const isIPv4Mapped =
bytes.slice(0, 10).every((byte) => byte === 0) &&
bytes[10] === 0xff &&
bytes[11] === 0xff;
if (isIPv4Mapped) {
return isPrivateIPv4(bytes.slice(12, 16));
}
return false;
}
function isPrivateIP(ip: string): boolean {
const ipVersion = net.isIP(ip);
if (ipVersion === 4) {
const bytes = parseIPv4(ip);
return bytes ? isPrivateIPv4(bytes) : false;
}
if (ipVersion === 6) {
const bytes = parseIPv6(ip);
return bytes ? isPrivateIPv6(bytes) : false;
}
return false;
}

Copilot uses AI. Check for mistakes.
interface MetadataResult {
title?: string;
description?: string;
Expand Down Expand Up @@ -77,6 +99,23 @@
);
}

// SSRF Prevention: Resolve the hostname and block internal/private IP networks
try {
const records = await dns.lookup(parsedUrl.hostname, { all: true });
if (records.some((r) => isPrivateIP(r.address))) {
return NextResponse.json(
{ success: false, error: 'Access to internal networks is forbidden' },
{ status: 403 }
);
}
Comment on lines +102 to +110
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

The SSRF mitigation only validates the initial hostname's DNS result. fetch() follows redirects by default, so an attacker could supply a public URL that redirects to a private/loopback/link-local address and bypass this check. To close this gap, either disable redirects (redirect: 'manual') or implement redirect-chain validation (resolving and checking each Location target before following).

Copilot uses AI. Check for mistakes.
} catch (_dnsError) {

Check failure on line 111 in src/app/api/lookup/url/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'_dnsError' is defined but never used

Check failure on line 111 in src/app/api/lookup/url/route.ts

View workflow job for this annotation

GitHub Actions / Lint

'_dnsError' is defined but never used
// If we can't resolve the domain, it's either invalid or intentionally hidden
return NextResponse.json(
{ success: false, error: 'Failed to resolve hostname' },
{ status: 400 }
);
}

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

Expand Down
Loading