diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a5eb1dd..a4aaafe 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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. diff --git a/src/app/api/lookup/url/route.ts b/src/app/api/lookup/url/route.ts index e01d434..8f5b7c1 100644 --- a/src/app/api/lookup/url/route.ts +++ b/src/app/api/lookup/url/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from 'next/server'; import type { SourceType } from '@/types'; +import { lookup } from 'dns/promises'; interface MetadataResult { title?: string; @@ -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> { try { const body = await request.json(); @@ -77,9 +117,27 @@ export async function POST(request: NextRequest): Promise