From e998ee1ae31599c5d89c5e7949abfa0a142a08da Mon Sep 17 00:00:00 2001 From: tomaioo Date: Fri, 31 Jul 2026 02:13:17 -0700 Subject: [PATCH] fix(pages): server-side request forgery (ssrf) in /api/fetch The `/api/fetch` endpoint takes a URL from the user request body and fetches it server-side without any validation. An attacker can use this to force the server to make requests to internal network resources (e.g., `http://localhost`, `http://169.254.169.254`), potentially exposing internal services or cloud metadata. Signed-off-by: tomaioo <203048277+tomaioo@users.noreply.github.com> --- src/pages/api/fetch.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/pages/api/fetch.ts b/src/pages/api/fetch.ts index 00a59b3e..e60da063 100644 --- a/src/pages/api/fetch.ts +++ b/src/pages/api/fetch.ts @@ -1,12 +1,58 @@ import { NextApiRequest, NextApiResponse } from 'next'; +import dns from 'dns'; +import net from 'net'; interface Body { url: string; } +const ALLOWED_PROTOCOLS = ['https:']; + +function isPrivateIp(ip: string): boolean { + if (net.isIPv4(ip)) { + const parts = ip.split('.').map(Number); + if (parts[0] === 10) return true; + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + if (parts[0] === 192 && parts[1] === 168) return true; + if (parts[0] === 127) return true; + if (parts[0] === 169 && parts[1] === 254) return true; + if (parts[0] === 0) return true; + } + if (net.isIPv6(ip)) { + const lower = ip.toLowerCase(); + if (lower === '::1' || lower === '::') return true; + if (lower.startsWith('fc') || lower.startsWith('fd')) return true; + if (lower.startsWith('fe80')) return true; + } + return false; +} + +async function validateUrl(urlStr: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + throw new Error('Invalid URL'); + } + if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) { + throw new Error('Protocol not allowed'); + } + const hostname = parsed.hostname; + if (isPrivateIp(hostname)) { + throw new Error('Internal addresses are not allowed'); + } + const addresses = await dns.promises.lookup(hostname, { all: true }); + for (const addr of addresses) { + if (isPrivateIp(addr.address)) { + throw new Error('Internal addresses are not allowed'); + } + } +} + export default async function fetchReq(req: NextApiRequest, res: NextApiResponse) { try { const body = JSON.parse(req.body as string) as Body; + await validateUrl(body.url); const text = await fetch(body.url).then((res) => res.text()); res.status(200).json({ ok: true, data: text }); } catch (err) {