Skip to content

Commit 4ba10e5

Browse files
Implement URL validation and sanitization for SSRF protection in route handling
1 parent 3f2deb1 commit 4ba10e5

2 files changed

Lines changed: 100 additions & 13 deletions

File tree

app/api/preview/route.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,77 @@
11
import {type NextRequest, NextResponse} from "next/server"
22
import 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, '&amp;')
70+
.replace(/</g, '&lt;')
71+
.replace(/>/g, '&gt;')
72+
.replace(/"/g, '&quot;')
73+
.replace(/'/g, '&#039;')
74+
}
375

476
export 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
/<head>/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, {

app/api/report/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ export async function POST(request: NextRequest) {
4141
const willLog = ![401, 403, 405].includes(response.status)
4242
if (willLog) {
4343
try {
44-
console.log(`[API] report: inserting log for ${projectSlug}${routePath} status=${response.status} responseTime=${responseTime}`)
44+
console.log('[API] report: inserting log for', projectSlug + routePath, 'status=', response.status, 'responseTime=', responseTime)
4545
await insertStatusLog(projectSlug, routePath, response.status, responseTime)
46-
console.log(`[API] report: insert successful for ${projectSlug}${routePath}`)
46+
console.log('[API] report: insert successful for', projectSlug + routePath)
4747
} catch (e) {
48-
console.error(`[API] report: insert failed for ${projectSlug}${routePath}:`, e)
48+
console.error('[API] report: insert failed for', projectSlug + routePath, ':', e)
4949
}
5050
} else {
51-
console.log(`[API] report: skipping insert for ${projectSlug}${routePath} status=${response.status}`)
51+
console.log('[API] report: skipping insert for', projectSlug + routePath, 'status=', response.status)
5252
}
5353

5454
const result: any = {
@@ -92,9 +92,9 @@ export async function POST(request: NextRequest) {
9292

9393
try {
9494
await insertStatusLog(projectSlug, routePath, 0, responseTime)
95-
console.log(`[API] report: insert error-log successful for ${projectSlug}${routePath} responseTime=${responseTime}`)
95+
console.log('[API] report: insert error-log successful for', projectSlug + routePath, 'responseTime=', responseTime)
9696
} catch (e) {
97-
console.error(`[API] report: failed to insert error-log for ${projectSlug}${routePath}:`, e)
97+
console.error('[API] report: failed to insert error-log for', projectSlug + routePath, ':', e)
9898
}
9999

100100
return NextResponse.json({

0 commit comments

Comments
 (0)