diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a5eb1dd..4f93902 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 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`. diff --git a/src/app/api/lookup/url/route.test.ts b/src/app/api/lookup/url/route.test.ts index 73449c6..f1b5e6d 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 globally to prevent SSRF block from failing tests +vi.mock('dns/promises', () => ({ + default: { + lookup: vi.fn().mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + } +})); + describe('URL Lookup API', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/app/api/lookup/url/route.ts b/src/app/api/lookup/url/route.ts index e01d434..a1e1b63 100644 --- a/src/app/api/lookup/url/route.ts +++ b/src/app/api/lookup/url/route.ts @@ -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:'); +} + interface MetadataResult { title?: string; description?: string; @@ -77,6 +99,23 @@ export async function POST(request: NextRequest): Promise isPrivateIP(r.address))) { + return NextResponse.json( + { success: false, error: 'Access to internal networks is forbidden' }, + { status: 403 } + ); + } + } catch (_dnsError) { + // 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);