الهجمتين دول نفس الفكرة: المستخدم بيبعت عنوان (URL أو اسم ملف)، والسيرفر بيروح عليه بصلاحياته هو، مش بصلاحيات المستخدم.
SSRF: السيرفر بيجيب حاجة من جوه
URL Paramالـ url اللي المستخدم بعته
Serverبيعمل fetch
Internal APIحاجة مش مكشوفة للنت
ميزة زي “اعمل preview للينك” أو “ارفع صورة من URL”. السيرفر بيعمل fetch للعنوان. بس السيرفر قاعد جوه الشبكة: يقدر يوصل لـ localhost:6379 (Redis)، لـ admin panel داخلي، أو لـ metadata endpoint بتاع الـ cloud اللي ممكن يدّي credentials.
preview.js
// ❌ أي URL يتجابapp.get("/preview", async (req, res) => { const r = await fetch(req.query.url); res.send(await r.text());});الحل: allow-list للخارج، وبلوك للداخل
preview.js
import { lookup } from "node:dns/promises";import net from "node:net";const ALLOWED_HOSTS = new Set(["images.example-cdn.com", "api.github.com"]);function isPrivate(ip) { if (net.isIPv6(ip)) return ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80"); const [a, b] = ip.split(".").map(Number); return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254); // cloud metadata}app.get("/preview", async (req, res) => { const url = new URL(req.query.url); if (url.protocol !== "https:") return res.sendStatus(400); if (!ALLOWED_HOSTS.has(url.hostname)) return res.sendStatus(403); const { address } = await lookup(url.hostname); if (isPrivate(address)) return res.sendStatus(403); const r = await fetch(url, { redirect: "error" }); // متتبعش redirect لعنوان تاني res.send(await r.text());});Path Traversal: ../ لحد ما يخرج بره
../ in Pathfile=../../.env
File APIfs.readFile
Secret Fileملف بره الفولدر
download.js
// ❌ ../../.env هيطلع من فولدر uploadsapp.get("/download", (req, res) => { res.sendFile(path.join(__dirname, "uploads", req.query.file));});path.join بيحل الـ ../ عادي، فالمسار النهائي ممكن يبقى في أي حتة على السيرفر.
الحل: resolve وبعدين اتأكد إنه لسه جوه
download.js
const ROOT = path.resolve(__dirname, "uploads");app.get("/download", (req, res) => { const target = path.resolve(ROOT, String(req.query.file)); // لازم يبدأ بالـ ROOT + فاصل، عشان uploads-old ميعديش if (!target.startsWith(ROOT + path.sep)) return res.sendStatus(403); res.sendFile(target);});خلّصت الفصل لما
🧠 اختبر نفسك
١) ليه 169.254.169.254 مهم في SSRF؟
ده metadata endpoint بتاع الـ cloud، وممكن يدّي credentials للسيرفر.
٢) عملت فحص إن الـ host مسموح، بس الـ fetch بيتبع redirects. المشكلة؟
الـ host المسموح ممكن يعمل redirect لعنوان داخلي. اقفل الـ redirect أو افحص كل خطوة.
٣) ليه startsWith(ROOT) لوحدها ممكن تغلط؟
/app/uploads-old بيبدأ بـ /app/uploads. لازم تضيف الفاصل path.sep.