تخطَّ إلى المحتوى
CodeAuditAgent
كل المقالات

الدفاع ضد SSRF في webhooks ومعاينات الروابط وجالبات عناوين URL

كيف يحوّل SSRF الـ webhooks ومعاينات الروابط وأدوات الاستيراد إلى طريق نحو البيانات الوصفية السحابية والخدمات الداخلية، والدفاعات التي تصمد فعلاً.

· قراءة في 7 دقائق · Lina Source LLC

أي ميزة تأخذ عنوان URL من المستخدم ثم تجلبه من خادمك هي ثغرة محتملة لتزوير الطلبات من جهة الخادم (SSRF، أي CWE-918). فـ webhooks ومعاينات الروابط، وجلب الصورة الرمزية من رابط، واستيراد RSS والتقويم، ومولّدات PDF، وأزرار «استيراد من رابط» تتشارك الشكل نفسه: خادمك يرسل طلبًا إلى وجهة يختارها شخص آخر.

المشكلة في موقع خادمك. فهو يستطيع بلوغ ما لا يستطيع المهاجم بلوغه: خدمة البيانات الوصفية السحابية، ولوحات الإدارة الداخلية، وقواعد بيانات بلا كلمات مرور على شبكة خاصة، وخدمات تعمل على localhost. يحوّل SSRF خادمك إلى وكيل للمهاجم داخل تلك الشبكة. وإدخاله سهل بوجه خاص في الفرق الصغيرة، لأن الميزة المسببة له تبدو غير ضارة: حقل رابط في صفحة إعدادات، وبطاقة معاينة في محادثة، ودالة مساعدة تنزّل صورة الملف الشخصي. لا شيء من هذا يبدو كودًا لأمن الشبكات، ولذلك نادرًا ما يُراجَع بهذه الصفة.

ما الذي يستهدفه المهاجم

  • نقاط نهاية البيانات الوصفية السحابية، وأشهرها 169.254.169.254 على AWS وGCP وAzure، والتي قد تعيد تفاصيل المثيل، وفي بعض الإعدادات بيانات اعتماد مؤقتة لدور الجهاز.
  • الخدمات المرتبطة بـ localhost، مثل واجهات الإدارة وخوادم التنقيح ونقاط نهاية المقاييس التي تفترض أن المستدعين محليون فقط.
  • الخدمات الداخلية على النطاقات الخاصة (10.0.0.0/8 و172.16.0.0/12 و192.168.0.0/16) التي لا تملك أي مصادقة لأنها لم تُصمَّم أصلًا لتكون قابلة للوصول من الخارج.
  • مسح المنافذ واكتشاف الخدمات، باستخدام أزمنة الاستجابة أو رسائل الخطأ لرسم خريطة الشبكة الداخلية.
  • البروتوكولات غير HTTP، إن كانت مكتبة الجلب تدعم مخططات مثل file: و gopher: و ftp:.

وحتى حين لا تعود الاستجابة إلى المهاجم، يظل SSRF الأعمى قادرًا على إطلاق طلبات تغيّر الحالة ضد نقاط نهاية داخلية. وكثيرًا ما تكون webhooks عمياء، وهذا لا يجعلها آمنة. فالكثير من الخدمات الداخلية تقبل طلبات GET بسيطة تغيّر الحالة، مثل إفراغ ذاكرة التخزين المؤقت أو إجراءات إدارية خلف رابط، وذلك تحديدًا لأنها تفترض أن لا أحد في الخارج يستطيع بلوغها.

لماذا تفشل الفحوص البسيطة

أول ما يخطر بالبال هو تحليل الرابط ورفض أسماء مضيفين مثل localhost أو سلاسل تبدأ بـ «10.» أو «192.168.». وهذا يفشل لأسباب عدة.

  • أسماء المضيفين تُترجم إلى عناوين IP. يسجّل المهاجم نطاقًا يشير سجل A الخاص به إلى 127.0.0.1 أو 169.254.169.254، فلا يرى فحص اسم المضيف أي خطأ.
  • لعناوين IP صيغ كتابة كثيرة: العشرية (2130706433)، والست عشرية، والصيغ المختصرة مثل 127.1، وIPv6 المُطابِق لـ IPv4 مثل ::ffff:127.0.0.1. ومطابقة السلاسل النصية تفوّتها.
  • إعادة ربط DNS: يُترجم النطاق إلى عنوان عام حين تتحقق منه، ثم إلى عنوان خاص بعد لحظة حين يتصل عميل HTTP. فالتحقق والاتصال عمليتا بحث منفصلتان.
  • عمليات إعادة التوجيه: الرابط الذي تحققت منه يعيد استجابة 302 إلى http://169.254.169.254/، فيتبعها عميل HTTP دون أن يسألك.

والخيط الجامع هو أن التحقق يجري على شيء غير العنوان الذي يتصل به المقبس فعليًا. والإصلاح هو فحص عنوان IP المُترجَم عند وقت الاتصال، لكل اتصال، بما في ذلك ما يلي عمليات إعادة التوجيه.

الدفاعات، من الأقوى إلى الأضعف

ضع قائمة سماح للوجهات متى أمكن

إذا كانت الميزة لا تحتاج إلا إلى التخاطب مع مجموعة معروفة من المضيفين، مثل تكامل مع حفنة من المزوّدين، فضع تلك الأسماء في قائمة سماح وارفض ما عداها. هذا أقوى ضابط وأسهلها على الفهم. وقارن اسم المضيف المحلَّل مطابقةً تامة، لا بفحوص startsWith أو endsWith التي تقبل مضيفين مشابهين في الشكل مثل api.example.com.attacker.net أو evilexample.com. وكل ما يلي موجّه للميزات التي يلزمها قبول روابط عامة عشوائية.

قيّد المخطط والمنفذ

اقبل https: فقط (وhttp: فقط إذا اضطررت). ارفض بيانات الاعتماد داخل الرابط، وقصر المنافذ على 443 و80 ما لم تكن هناك حاجة واضحة. وحلّل الرابط بمحلّل URL قياسي، لا بتعبير نمطي أبدًا؛ فمحلّل WHATWG يوحّد كذلك صيغ كتابة IP الغريبة، وهو ما يساعد الفحوص اللاحقة.

ترجِم العنوان، ثم افحص IP عند وقت الاتصال

احجب نطاقات الاسترجاع المحلي، والنطاقات الخاصة، والمحلية للوصلة، وNAT على مستوى المشغّل، وغير المحددة، ونطاقات IPv6 المحلية الفريدة والمحلية للوصلة. والأهم أن تطبّق الفحص داخل بحث DNS الذي يستخدمه عميل HTTP، حتى يكون العنوان الذي يُتحقق منه هو العنوان الذي يُتصل به. وهذا يغلق ثغرة إعادة ربط DNS. ووحدتا http وhttps في Node تقبلان دالة lookup مخصصة لهذا الغرض بالضبط.

تغطي قائمة الحجب أدناه النطاقات المهمة لمعظم عمليات النشر. أضف أي نطاقات عامة تعود لبنيتك التحتية، فموازن الأحمال أو واجهة برمجة داخلية بعنوان عام قد يظل يثق بالطلبات القادمة من داخل شبكتك. وارفض اسم المضيف إذا كان أي من عناوينه المُترجَمة محجوبًا، لا العنوان الأول فحسب، لأن العميل قد يجرّبها بأي ترتيب.

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4-mapped
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// Validates every resolved address before the socket connects
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

وثمة تفصيل يسهل إغفاله: حين يحتوي الرابط على عنوان IP صريح، يتصل Node مباشرة دون استدعاء lookup إطلاقًا. لذا على دالة الطلب أن تفحص عناوين IP الصريحة بنفسها قبل التسليم.

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https never follows redirects; a 3xx is treated as a failure
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

وإذا كنت تستخدم fetch في Node، وهو مبني على undici، فالفكرة نفسها تنطبق عبر موزّع مخصص: أنشئ وكيل Agent من undici مع خيار connect.lookup ومرّره بوصفه dispatcher. والمبدأ لا يتغير: الفحص يقيم حيث يُنشأ الاتصال. وإذا كانت بيئتك توجّه حركة المرور الصادرة عبر وكيل HTTP، فانتبه إلى أن البحث يجري عندئذٍ على الوكيل لصالح المضيف الهدف، ولذا يجب أن يفرض الوكيل نفسه القواعد ذاتها.

عطّل إعادة التوجيه، أو أعد التحقق من كل قفزة

بالنسبة إلى webhooks، لا تتبع عمليات إعادة التوجيه إطلاقًا؛ فالمستقبِل الذي يعيد التوجيه مُعَدّ بشكل خاطئ، والفشل الصريح يخبر العميل بإصلاح نقطة نهايته. أما في معاينات الروابط وأدوات الاستيراد، حيث تكون إعادة التوجيه أمرًا طبيعيًا، فاتبعها يدويًا بحدّ صغير ومرّر كل قفزة على فحوص المخطط والمنفذ وIP نفسها. ومع fetch، اضبط redirect: "manual" وتولَّ ترويسة Location بنفسك.

قيّد ما يمكن أن يفعله الطلب الناجح

  • اضبط مهلة للاتصال ومهلة إجمالية، وضع حدًا أقصى لحجم الاستجابة، حتى لا يُستخدم الجالب لإبقاء الاتصالات مفتوحة أو لسحب ملفات كبيرة.
  • لا تُعِد استجابات خامًا أو رسائل خطأ تفصيلية إلى المستخدم. وفي معاينات الروابط، أعد العنوان والوصف ورابط الصورة المستخرجة فقط.
  • جرّد ترويسات المصادقة وملفات تعريف الارتباط الخاصة بك؛ فالجالب يجب ألا يرسل بيانات اعتماد داخلية إلى مضيفين يقدمهم المستخدم.

وجّه الحركة عبر وكيل خروج

أمتن إعداد ينقل السياسة خارج كود التطبيق. شغّل عمليات الجلب الصادرة التي يقودها المستخدم عبر وكيل خروج مخصص، أو من عامل معزول في جزء من الشبكة لا يملك ببساطة أي مسار إلى الخدمات الداخلية أو نقطة نهاية البيانات الوصفية. عندئذٍ لا يتحول خلل في التحقق من الروابط إلى اختراق، لأن الشبكة نفسها ترفض. وتستطيع وكلاء التوجيه مفتوحة المصدر فرض قواعد الوجهة نيابة عنك، كما يعزل العامل المنفصل الاستجابات البطيئة أو العدائية عن عملية الويب الرئيسية.

حصّن خدمة البيانات الوصفية

على AWS، اشترط IMDSv2 الذي يحتاج رمز جلسة يُحصل عليه بطلب PUT، وأبقِ حدّ القفزات عند 1 حتى لا تستطيع الحاويات بلوغه عبر المضيف. وتشترط GCP وAzure ترويسة طلب محددة في استدعاءات البيانات الوصفية. وهذه الإجراءات ترفع العتبة كثيرًا أمام SSRF البسيط القائم على GET، لكنها طبقة ثانية لا بديل عن حجب العناوين المحلية للوصلة. وامنح كذلك دور المثيل أو الخدمة الصلاحيات التي يحتاجها التطبيق فقط، حتى تفتح بيانات الاعتماد المسروقة عبر خدمة البيانات الوصفية أقل قدر ممكن.

اختبار دفاعاتك

  • جرّب http://127.0.0.1/ وhttp://[::1]/ وhttp://2130706433/ وhttp://127.1/ وhttp://169.254.169.254/latest/meta-data/ وتأكد من رفض كل منها.
  • وجّه اسم مضيف تملكه إلى 127.0.0.1 وتأكد من أن فحص البحث يرفضه. ثم امنحه سجلَّي A، أحدهما عام والآخر خاص، وتأكد من أنه ما زال مرفوضًا.
  • قدّم إعادة توجيه 302 إلى عنوان خاص من مضيف عام وتأكد من عدم اتباعها.
  • جرّب روابط file: وftp: وgopher:، وروابط تحمل بيانات اعتماد أو منافذ غير معتادة.

وفي مراجعة الكود، ابحث عن كل موضع يُبنى فيه رابط طلب من مدخلات: fetch وaxios وgot وrequests وhttp.Get واستدعاءات page.goto في المتصفحات بلا واجهة ومكتبات معالجة الصور التي تقبل روابط. يتتبّع CodeAuditAgent تدفق البيانات هذا في المستودعات العامة والمقتطفات الملصوقة، ويبلّغ عن SSRF بوصفه CWE-918 مع موقع الاستدعاء المقتبس وسيناريو استغلال وتصحيح.