diff --git a/src/routes/api/apps/preview/+server.ts b/src/routes/api/apps/preview/+server.ts index 5fce609..45530f2 100644 --- a/src/routes/api/apps/preview/+server.ts +++ b/src/routes/api/apps/preview/+server.ts @@ -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();