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:
@@ -10,6 +10,37 @@ const previewSchema = z.object({
|
|||||||
|
|
||||||
const PREVIEW_TIMEOUT_MS = 10_000;
|
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.
|
* Extract the page title from HTML content.
|
||||||
*/
|
*/
|
||||||
@@ -71,6 +102,21 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
|
|
||||||
const { url } = parsed.data;
|
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 {
|
try {
|
||||||
// HEAD request for status and timing
|
// HEAD request for status and timing
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user