diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a5eb1dd..c9d0df8 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,13 @@ **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 Unrestricted Node.js fetch] +**Vulnerability:** The application used Node.js native `fetch` to scrape arbitrary URLs supplied by the user (`/api/lookup/url`) without checking the resolved IP address. +**Learning:** This exposes internal services (e.g., `127.0.0.1`, AWS metadata `169.254.169.254`, or VPC private subnets) to Server-Side Request Forgery (SSRF). Attackers can bypass domain-based blocklists by pointing their domain to an internal IP (DNS rebinding) or simply providing a local IP. +**Prevention:** To prevent SSRF when using native `fetch`, validate protocols (e.g., only `http:` and `https:`) and perform pre-fetch DNS validation using `dns.lookup`. Ensure the resolved IP does not fall within private, loopback, or reserved IP ranges before initiating the HTTP request. + +## 2025-02-28 - [DNS Rebinding Mitigation Tradeoffs] +**Vulnerability:** Even when performing pre-fetch DNS validation before calling native `fetch(url)` in Node.js to protect against SSRF, an attacker can use DNS Rebinding to switch the IP address between the Time-Of-Check (TOC) and Time-Of-Use (TOU). +**Learning:** Fixing TOCTOU completely in native `fetch` by rewriting the URL to use the resolved IP breaks Server Name Indication (SNI) and TLS certificate validation for HTTPS requests, making it impractical without introducing other major flaws. +**Prevention:** Full protection against DNS rebinding while preserving SNI in Node.js requires using lower-level custom HTTP agents (like `undici` directly) with pinned DNS resolution. Since this is an architectural change, the initial pre-fetch validation check provides the best defense-in-depth tradeoff for this specific codebase constraints. diff --git a/src/app/api/lookup/url/route.test.ts b/src/app/api/lookup/url/route.test.ts index 73449c6..1bd96c5 100644 --- a/src/app/api/lookup/url/route.test.ts +++ b/src/app/api/lookup/url/route.test.ts @@ -5,6 +5,13 @@ import { NextRequest } from 'next/server'; // Mock fetch globally global.fetch = vi.fn(); +// Mock dns promises +vi.mock('dns/promises', () => ({ + lookup: vi.fn().mockResolvedValue({ address: '93.184.216.34', family: 4 }), +})); + +import { lookup } from 'dns/promises'; + describe('URL Lookup API', () => { beforeEach(() => { vi.clearAllMocks(); @@ -38,6 +45,53 @@ describe('URL Lookup API', () => { expect(data.error).toContain('Invalid URL format'); }); + it('should return error for invalid protocol', async () => { + const request = new NextRequest('http://localhost/api/lookup/url', { + method: 'POST', + body: JSON.stringify({ url: 'file:///etc/passwd' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain('Invalid protocol'); + }); + + it('should return error for private IP addresses (SSRF protection)', async () => { + // Mock lookup to return a private IP + vi.mocked(lookup).mockResolvedValueOnce({ address: '127.0.0.1', family: 4 }); + + const request = new NextRequest('http://localhost/api/lookup/url', { + method: 'POST', + body: JSON.stringify({ url: 'http://localhost:8080/admin' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.success).toBe(false); + expect(data.error).toContain('Access to internal networks is forbidden'); + }); + + it('should return error if DNS lookup fails', async () => { + vi.mocked(lookup).mockRejectedValueOnce(new Error('ENOTFOUND')); + + const request = new NextRequest('http://localhost/api/lookup/url', { + method: 'POST', + body: JSON.stringify({ url: 'http://this-domain-does-not-exist-1234.com' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toContain('Could not resolve hostname'); + }); + it('should extract metadata from HTML', async () => { const mockHtml = ` diff --git a/src/app/api/lookup/url/route.ts b/src/app/api/lookup/url/route.ts index e01d434..d69e782 100644 --- a/src/app/api/lookup/url/route.ts +++ b/src/app/api/lookup/url/route.ts @@ -9,8 +9,43 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { lookup } from 'dns/promises'; import type { SourceType } from '@/types'; +// Utility to check if an IP is private/local +function isPrivateIP(ip: string): boolean { + // IPv4 Private/Reserved Ranges + if ( + ip.startsWith('10.') || + ip.startsWith('127.') || + ip.startsWith('169.254.') || + ip.startsWith('192.168.') || + ip === '0.0.0.0' || + ip === '255.255.255.255' + ) { + return true; + } + + // 172.16.0.0/12 + if (ip.startsWith('172.')) { + const secondOctet = parseInt(ip.split('.')[1], 10); + if (secondOctet >= 16 && secondOctet <= 31) return true; + } + + // IPv6 Private/Reserved Ranges + if ( + ip === '::1' || + ip === '::' || + ip.toLowerCase().startsWith('fc00:') || + ip.toLowerCase().startsWith('fd00:') || + ip.toLowerCase().startsWith('fe80:') + ) { + return true; + } + + return false; +} + interface MetadataResult { title?: string; description?: string; @@ -77,6 +112,33 @@ export async function POST(request: NextRequest): Promise