السيرفر · فصل ٤

SSRF و Path Traversal — لما السيرفر يثق في URL أو مسار

Server-Side Request Forgery · allow-list · metadata endpoint · Path Traversal · path.resolve
🎯 الهدف: تمنع السيرفر إنه يروح لأي عنوان المستخدم بيقوله عليه، وتمنع أي مسار ملف يخرج بره الفولدر المسموح.

الهجمتين دول نفس الفكرة: المستخدم بيبعت عنوان (URL أو اسم ملف)، والسيرفر بيروح عليه بصلاحياته هو، مش بصلاحيات المستخدم.

SSRF: السيرفر بيجيب حاجة من جوه

ميزة زي “اعمل 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: ../ لحد ما يخرج بره

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.