सामग्री पर जाएँ
CodeAuditAgent
सभी लेख

Webhooks, Link Previews और URL Fetchers के लिए SSRF बचाव

SSRF कैसे webhooks, link previews और importers को cloud metadata तथा internal services तक का रास्ता बना देता है, और कौन-से बचाव टिकते हैं, Node.js उदाहरण सहित।

· 7 मिनट का लेख · Lina Source LLC

कोई भी फ़ीचर जो यूज़र से URL लेकर उसे आपके सर्वर से fetch करता है, संभावित server-side request forgery (SSRF, CWE-918) है। Webhooks, link previews, URL से avatar, RSS और कैलेंडर imports, PDF renderers और URL से import करने वाले बटन, सबका आकार एक जैसा है: आपका सर्वर किसी और के चुने हुए destination पर request भेजता है।

समस्या यह है कि आपका सर्वर कहाँ बैठा है। वह उन चीज़ों तक पहुँच सकता है जहाँ हमलावर नहीं पहुँच सकता: cloud metadata सर्विस, internal admin पैनल, प्राइवेट नेटवर्क पर बिना पासवर्ड वाले डेटाबेस, और localhost पर चल रही सर्विसें। SSRF आपके सर्वर को उस नेटवर्क में हमलावर का proxy बना देता है। छोटी टीमों में यह ख़ास तौर पर आसानी से आ जाता है, क्योंकि इसे पैदा करने वाला फ़ीचर मासूम दिखता है: settings पेज में एक URL फ़ील्ड, चैट में एक preview कार्ड, प्रोफ़ाइल तस्वीर डाउनलोड करने वाला एक helper। इनमें से कोई नेटवर्क सिक्योरिटी कोड जैसा नहीं लगता, इसलिए इनका रिव्यू वैसे शायद ही कभी होता है।

हमलावर किस चीज़ के पीछे होता है

  • Cloud metadata endpoints, जिनमें सबसे मशहूर AWS, GCP और Azure पर 169.254.169.254 है, जो instance की जानकारी और कुछ कॉन्फ़िगरेशन में मशीन के role के अस्थायी credentials लौटा सकता है।
  • localhost से बंधी सर्विसें, जैसे admin इंटरफ़ेस, debug सर्वर और metrics endpoints जो मान लेते हैं कि सिर्फ़ local कॉलर ही आएँगे।
  • प्राइवेट ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) पर चल रही internal सर्विसें जिनमें कोई authentication नहीं, क्योंकि उन्हें बाहर से पहुँच में होना कभी सोचा ही नहीं गया।
  • Port scanning और service discovery, जिसमें response समय या error messages से internal नेटवर्क का नक़्शा बनाया जाता है।
  • ग़ैर-HTTP प्रोटोकॉल, अगर fetch लाइब्रेरी file:, gopher: या ftp: जैसी schemes सपोर्ट करती है।

भले ही response हमलावर को न लौटाया जाए, एक blind SSRF फिर भी internal endpoints पर state बदलने वाली requests चला सकता है। Webhooks अक्सर blind होते हैं, और इससे वे सुरक्षित नहीं हो जाते। कई internal सर्विसें साधारण GET requests स्वीकार करती हैं जो state बदल देती हैं, जैसे cache purge या URL के पीछे छिपे admin actions, ठीक इसलिए क्योंकि वे मान लेती हैं कि बाहर से कोई पहुँच ही नहीं सकता।

साधारण चेक क्यों नाकाम होते हैं

पहली सहज प्रतिक्रिया होती है URL parse करके localhost जैसे hostnames या 10. या 192.168. से शुरू होने वाली strings अस्वीकार करना। यह कई वजहों से नाकाम रहता है।

  • Hostnames IPs में resolve होते हैं। हमलावर ऐसा डोमेन रजिस्टर करता है जिसका A रिकॉर्ड 127.0.0.1 या 169.254.169.254 की ओर इशारा करता है, और hostname चेक को कुछ ग़लत नहीं दिखता।
  • IP पतों की कई वर्तनी होती है: decimal (2130706433), hex, 127.1 जैसे छोटे रूप, और ::ffff:127.0.0.1 जैसे IPv4-mapped IPv6। String मिलान इन्हें चूक जाता है।
  • DNS rebinding: जब आप validate करते हैं तब डोमेन पब्लिक IP पर resolve होता है और एक पल बाद जब HTTP क्लाइंट कनेक्ट करता है तब प्राइवेट IP पर। Validate करना और कनेक्ट करना दो अलग lookups हैं।
  • Redirects: जिस URL को आपने validate किया वह http://169.254.169.254/ पर 302 लौटाता है, और HTTP क्लाइंट बिना पूछे उसका पीछा कर लेता है।

साझा सूत्र यह है कि validation किसी ऐसी चीज़ पर होती है जो वह पता नहीं है जिससे socket असल में कनेक्ट करता है। फ़िक्स यह है कि connection के समय resolved IP जाँचें, हर connection के लिए, redirects के बाद भी।

बचाव, सबसे मज़बूत पहले

जहाँ संभव हो, destinations को allowlist करें

अगर फ़ीचर को सिर्फ़ ज्ञात hosts के एक सेट से बात करनी है, जैसे गिने-चुने प्रोवाइडर्स के साथ कोई इंटीग्रेशन, तो उन hostnames को allowlist करें और बाक़ी सब अस्वीकार करें। यह सबसे मज़बूत नियंत्रण है और समझने में सबसे आसान। Parse किए गए hostname की तुलना ठीक-ठीक करें, startsWith या endsWith चेक से नहीं, जो api.example.com.attacker.net या evilexample.com जैसे मिलते-जुलते hosts स्वीकार कर लेते हैं। नीचे जो कुछ है वह उन फ़ीचर्स के लिए है जिन्हें मनमाने पब्लिक URLs स्वीकार करने ही होते हैं।

Scheme और port सीमित करें

सिर्फ़ https: स्वीकार करें (और http: तभी जब मजबूरी हो)। URL में credentials अस्वीकार करें और ports को 443 तथा 80 तक सीमित रखें जब तक साफ़ ज़रूरत न हो। किसी मानक URL parser से parse करें, कभी regex से नहीं; WHATWG parser अजीब IP वर्तनियों को normalize भी कर देता है, जिससे बाद के चेक में मदद मिलती है।

Resolve करें, फिर connect समय पर IP जाँचें

Loopback, private, link-local, carrier-grade NAT, unspecified और IPv6 unique-local तथा link-local ranges ब्लॉक करें। सबसे अहम, यह चेक उसी DNS lookup के भीतर लगाएँ जिसे HTTP क्लाइंट इस्तेमाल करता है, ताकि जो पता validate हो वही पता connect भी हो। इससे DNS rebinding का रास्ता बंद होता है। Node के http और https modules ठीक इसी के लिए एक custom lookup फ़ंक्शन स्वीकार करते हैं।

नीचे दी गई block list ज़्यादातर deployments के लिए मायने रखने वाली ranges कवर करती है। अपनी इंफ्रास्ट्रक्चर की कोई भी पब्लिक range भी जोड़ें, क्योंकि पब्लिक IP वाला load balancer या internal API भी आपके नेटवर्क के भीतर से आई requests पर भरोसा कर सकता है। किसी hostname को अस्वीकार करें अगर उसका कोई भी resolved पता ब्लॉक है, सिर्फ़ पहला नहीं, क्योंकि क्लाइंट उन्हें किसी भी क्रम में आज़मा सकता है।

// 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");
}

// socket के connect करने से पहले हर resolved पते को validate करता है
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);
  });
}

एक बात आसानी से छूट जाती है: जब URL में कोई IP literal हो, तो Node lookup कॉल किए बिना ही सीधे connect कर लेता है। इसलिए request फ़ंक्शन को आगे बढ़ने से पहले IP literals ख़ुद जाँचने होते हैं।

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 कभी redirects का पीछा नहीं करता; 3xx को विफलता माना जाता है
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

अगर आप Node में fetch इस्तेमाल करते हैं, जो undici पर बना है, तो वही विचार एक custom dispatcher से लागू होता है: connect.lookup विकल्प के साथ एक undici Agent बनाएँ और उसे dispatcher के रूप में पास करें। सिद्धांत नहीं बदलता: चेक वहीं रहता है जहाँ connection बनता है। अगर आपका environment outbound ट्रैफ़िक किसी HTTP proxy से भेजता है, तो ध्यान रखें कि तब lookup target host के लिए proxy पर होता है, इसलिए proxy को ही वही नियम लागू करने होंगे।

Redirects बंद करें, या हर hop फिर से validate करें

Webhooks के लिए redirects का पीछा बिल्कुल न करें; redirect करने वाला receiver ग़लत कॉन्फ़िगर है, और खुलकर फ़ेल होना कस्टमर को उनका endpoint ठीक करने का संकेत देता है। Link previews और importers के लिए, जहाँ redirects सामान्य हैं, उनका पीछा एक छोटी सीमा के साथ ख़ुद करें और हर hop को उन्हीं scheme, port और IP चेक से गुज़ारें। fetch में redirect: "manual" सेट करें और Location header ख़ुद संभालें।

सफल request क्या कर सकती है, यह सीमित करें

  • Connect और कुल timeouts सेट करें, और response का आकार सीमित करें, ताकि fetcher से connections खुले रखवाए या बड़ी फ़ाइलें खिंचवाई न जा सकें।
  • Raw responses या विस्तृत error messages यूज़र को न लौटाएँ। Link previews के लिए सिर्फ़ निकाला गया title, description और image URL लौटाएँ।
  • अपने authentication headers और cookies हटा दें; fetcher को यूज़र के दिए hosts पर internal credentials कभी नहीं भेजने चाहिए।

Egress proxy से रूट करें

सबसे मज़बूत सेटअप policy को एप्लिकेशन कोड से बाहर ले जाता है। यूज़र-चालित outbound fetches एक समर्पित egress proxy से चलाएँ, या किसी ऐसे नेटवर्क सेगमेंट के अलग worker से जिसका internal सर्विसों या metadata endpoint तक कोई route ही न हो। तब URL validation का कोई बग breach नहीं बनता, क्योंकि नेटवर्क ख़ुद मना कर देता है। ओपन-सोर्स forward proxies आपके लिए destination नियम लागू कर सकते हैं, और अलग worker धीमे या शत्रुतापूर्ण responses को आपकी मुख्य वेब प्रोसेस से अलग भी रखता है।

Metadata सर्विस को मज़बूत करें

AWS पर IMDSv2 अनिवार्य करें, जिसमें PUT request से लिया गया session token चाहिए, और hop limit 1 रखें ताकि containers host के ज़रिए उस तक न पहुँच सकें। GCP और Azure metadata कॉल पर एक ख़ास request header माँगते हैं। ये साधारण GET-आधारित SSRF के लिए कसौटी काफ़ी ऊँची कर देते हैं, पर ये दूसरी परत हैं, link-local पतों को ब्लॉक करने का विकल्प नहीं। साथ ही instance या service role को सिर्फ़ वही permissions दें जो ऐप को चाहिए, ताकि metadata सर्विस से चुराए गए credentials जितना कम हो सके उतना ही खोलें।

अपने बचावों की टेस्टिंग

  • http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ और http://169.254.169.254/latest/meta-data/ आज़माएँ और पुष्टि करें कि हर एक अस्वीकार होता है।
  • अपने नियंत्रण वाले किसी hostname को 127.0.0.1 पर इंगित करें और पुष्टि करें कि lookup चेक उसे अस्वीकार करता है। फिर उसे दो A records दें, एक पब्लिक और एक प्राइवेट, और पुष्टि करें कि वह अब भी अस्वीकार होता है।
  • किसी पब्लिक host से प्राइवेट पते पर 302 redirect परोसें और पुष्टि करें कि उसका पीछा नहीं किया जाता।
  • file:, ftp: और gopher: URLs आज़माएँ, और credentials या असामान्य ports वाले URLs भी।

कोड रिव्यू में हर वह जगह खोजें जहाँ request URL इनपुट से बनता है: fetch, axios, got, requests, http.Get, headless browser के page.goto कॉल और URLs स्वीकार करने वाली image processing लाइब्रेरीज़। CodeAuditAgent पब्लिक रिपॉज़िटरीज़ और पेस्ट किए गए स्निपेट्स में उस डेटा फ़्लो को ट्रेस करता है और SSRF को CWE-918 के रूप में कोट की गई कॉल साइट, एक exploit परिदृश्य और एक पैच के साथ रिपोर्ट करता है।