fix: add SSRF protection to URL preview endpoint

Block requests to private/reserved IP ranges (10.x, 172.16-31.x,
192.168.x, 127.x, 169.254.x, localhost, ::1) and non-http(s)
schemes in the /api/apps/preview endpoint to prevent server-side
request forgery.
This commit is contained in:
2026-03-25 14:37:17 +03:00
parent 819283fa62
commit d90507ad82
+46
View File
@@ -10,6 +10,37 @@ const previewSchema = z.object({
const PREVIEW_TIMEOUT_MS = 10_000;
/**
* Block requests to private/reserved IP ranges to prevent SSRF.
*/
function isPrivateOrReservedHost(hostname: string): boolean {
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0'
) {
return true;
}
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 0) return true;
}
if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) {
return true;
}
return false;
}
/**
* Extract the page title from HTML content.
*/
@@ -71,6 +102,21 @@ export const POST: RequestHandler = async (event) => {
const { url } = parsed.data;
// SSRF protection: block private/reserved IPs and non-http(s) schemes
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return json(error('Only http and https URLs are allowed'), { status: 400 });
}
if (isPrivateOrReservedHost(parsedUrl.hostname)) {
return json(error('URLs pointing to private or reserved IP ranges are not allowed'), {
status: 400
});
}
} catch {
return json(error('Invalid URL'), { status: 400 });
}
try {
// HEAD request for status and timing
const startTime = Date.now();