11import { type NextRequest , NextResponse } from "next/server"
22import settings from '@/lib/settings'
3+ import { projects } from '@/lib/projectData'
4+
5+ // Extract allowed domains from project data
6+ const allowedDomains = new Set (
7+ projects . map ( p => {
8+ try {
9+ return new URL ( p . visitLink ) . hostname . toLowerCase ( )
10+ } catch {
11+ return null
12+ }
13+ } ) . filter ( ( domain ) : domain is string => domain !== null )
14+ )
15+
16+ // Helper function to validate and sanitize URLs for SSRF protection
17+ function validateUrl ( url : string ) : { valid : boolean ; error ?: string ; sanitized ?: string } {
18+ try {
19+ const parsedUrl = new URL ( url )
20+
21+ // Only allow http and https protocols
22+ if ( ! [ 'http:' , 'https:' ] . includes ( parsedUrl . protocol ) ) {
23+ return { valid : false , error : 'Only HTTP and HTTPS protocols are allowed' }
24+ }
25+
26+ // Check if domain is in the allowed list from projectData
27+ const hostname = parsedUrl . hostname . toLowerCase ( )
28+ if ( ! allowedDomains . has ( hostname ) ) {
29+ return { valid : false , error : 'Domain not allowed. Only configured project domains are permitted.' }
30+ }
31+
32+ // Block localhost variants (additional safety check)
33+ if ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0' || hostname === '[::1]' ) {
34+ return { valid : false , error : 'Cannot access localhost' }
35+ }
36+
37+ // Block private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x)
38+ const ipv4Regex = / ^ ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) $ /
39+ const ipMatch = hostname . match ( ipv4Regex )
40+ if ( ipMatch ) {
41+ const octets = ipMatch . slice ( 1 ) . map ( Number )
42+ const [ a , b ] = octets
43+ if (
44+ a === 10 ||
45+ ( a === 172 && b >= 16 && b <= 31 ) ||
46+ ( a === 192 && b === 168 ) ||
47+ ( a === 169 && b === 254 ) ||
48+ a === 0 ||
49+ a >= 224 // Multicast and reserved
50+ ) {
51+ return { valid : false , error : 'Cannot access private IP addresses' }
52+ }
53+ }
54+
55+ // Block IPv6 private addresses
56+ if ( hostname . includes ( ':' ) && ( hostname . startsWith ( 'fe80:' ) || hostname . startsWith ( 'fc' ) || hostname . startsWith ( 'fd' ) ) ) {
57+ return { valid : false , error : 'Cannot access private IPv6 addresses' }
58+ }
59+
60+ return { valid : true , sanitized : parsedUrl . href }
61+ } catch ( err ) {
62+ return { valid : false , error : 'Invalid URL format' }
63+ }
64+ }
65+
66+ // Helper function to escape HTML to prevent XSS
67+ function escapeHtml ( unsafe : string ) : string {
68+ return unsafe
69+ . replace ( / & / g, '&' )
70+ . replace ( / < / g, '<' )
71+ . replace ( / > / g, '>' )
72+ . replace ( / " / g, '"' )
73+ . replace ( / ' / g, ''' )
74+ }
375
476export async function GET ( request : NextRequest ) {
577 const searchParams = request . nextUrl . searchParams
@@ -9,10 +81,16 @@ export async function GET(request: NextRequest) {
981 return NextResponse . json ( { error : "Missing url parameter" } , { status : 400 } )
1082 }
1183
84+ // Validate URL to prevent SSRF
85+ const validation = validateUrl ( url )
86+ if ( ! validation . valid ) {
87+ return NextResponse . json ( { error : validation . error } , { status : 400 } )
88+ }
89+
1290 try {
1391 // We want to detect redirects and, if present, wait a configured amount before following them.
1492 const maxRedirects = settings . preview . maxRedirects
15- let currentUrl = url
93+ let currentUrl = validation . sanitized !
1694 let response : Response | null = null
1795
1896 for ( let i = 0 ; i <= maxRedirects ; i ++ ) {
@@ -35,7 +113,15 @@ export async function GET(request: NextRequest) {
35113
36114 // Resolve relative redirects against current URL
37115 try {
38- currentUrl = new URL ( location , currentUrl ) . href
116+ const resolvedUrl = new URL ( location , currentUrl ) . href
117+
118+ // Validate redirect URL to prevent SSRF
119+ const redirectValidation = validateUrl ( resolvedUrl )
120+ if ( ! redirectValidation . valid ) {
121+ return NextResponse . json ( { error : `Invalid redirect location: ${ redirectValidation . error } ` } , { status : 502 } )
122+ }
123+
124+ currentUrl = redirectValidation . sanitized !
39125 // Continue loop to fetch the redirected location
40126 continue
41127 } catch ( err ) {
@@ -55,7 +141,7 @@ export async function GET(request: NextRequest) {
55141 // If the target explicitly forbids GET (method mismatch), surface a readable message
56142 if ( response . status === 405 ) {
57143 return new NextResponse (
58- `<html lang="en"><body><h1>405 Method Not Allowed</h1><p>The requested URL ${ currentUrl } returned 405. Preview is not available for non-GET endpoints.</p></body></html>` ,
144+ `<html lang="en"><body><h1>405 Method Not Allowed</h1><p>The requested URL ${ escapeHtml ( currentUrl ) } returned 405. Preview is not available for non-GET endpoints.</p></body></html>` ,
59145 {
60146 headers : { "Content-Type" : "text/html" } ,
61147 status : 405 ,
@@ -69,12 +155,13 @@ export async function GET(request: NextRequest) {
69155
70156 // If configured to wait for full client-side load, return an iframe wrapper that waits for load event
71157 if ( settings . preview . waitForFullLoad ) {
158+ const escapedUrl = escapeHtml ( currentUrl )
72159 const iframeHtml = `<!doctype html>
73160<html lang="en">
74161<head>
75162<meta charset="utf-8" />
76163<meta name="viewport" content="width=device-width,initial-scale=1" />
77- <title>Preview: ${ currentUrl } </title>
164+ <title>Preview: ${ escapedUrl } </title>
78165<style>
79166 html,body { height:100%; margin:0; font-family:system-ui,Segoe UI,Roboto,Arial; }
80167 #frame { width:100%; height:100vh; border:0; display:none }
@@ -95,9 +182,9 @@ export async function GET(request: NextRequest) {
95182 <div style="font-size:32px;opacity:0.3;">⚠</div>
96183 <div style="font-size:14px;font-weight:500;">Preview Unavailable</div>
97184 <div style="font-size:12px;">This site cannot be displayed in a preview frame.</div>
98- <a href="${ currentUrl } " target="_blank" rel="noopener noreferrer" style="font-size:12px;">Open in new tab →</a>
185+ <a href="${ escapedUrl } " target="_blank" rel="noopener noreferrer" style="font-size:12px;">Open in new tab →</a>
99186</div>
100- <iframe id="frame" src="${ currentUrl } " sandbox="allow-same-origin allow-scripts allow-forms allow-pointer-lock allow-popups"></iframe>
187+ <iframe id="frame" src="${ escapedUrl } " sandbox="allow-same-origin allow-scripts allow-forms allow-pointer-lock allow-popups"></iframe>
101188<script>
102189 const iframe = document.getElementById('frame');
103190 const loader = document.getElementById('loader');
@@ -147,7 +234,7 @@ export async function GET(request: NextRequest) {
147234
148235 html = html . replace (
149236 / < h e a d > / i,
150- `<head><base href="${ currentUrl } "><style>body { pointer-events: none; user-select: none; }</style>` ,
237+ `<head><base href="${ escapeHtml ( currentUrl ) } "><style>body { pointer-events: none; user-select: none; }</style>` ,
151238 )
152239
153240 return new NextResponse ( html , {
0 commit comments