CodeAuditAgent
Tüm yazılar
  • Güvenlik
  • SSRF
  • Node.js

Webhook'lar, Bağlantı Önizlemeleri ve URL Çekiciler için SSRF Savunması

SSRF, webhook'ları, bağlantı önizlemelerini ve içe aktarıcıları bulut metadata'sına ve dahili servislere nasıl açar; işe yarayan savunmalar ve bir Node.js örneği.

· 7 dk okuma · Lina Source LLC

Kullanıcıdan bir URL alıp onu sunucunuzdan çeken her özellik, potansiyel bir sunucu taraflı istek sahteciliği (SSRF, CWE-918) açığıdır. Webhook'lar, bağlantı önizlemeleri, URL'den avatar yükleme, RSS ve takvim içe aktarmaları, PDF oluşturucular ve “URL'den içe aktar” düğmeleri aynı yapıyı paylaşır: sunucunuz, başkasının seçtiği bir hedefe istek gönderir.

Sorun, sunucunuzun konumudur. Sunucunuz saldırganın ulaşamadığı yerlere ulaşabilir: bulut metadata servisi, dahili yönetici panelleri, özel ağdaki parolasız veritabanları ve localhost üzerindeki servisler. SSRF, sunucunuzu saldırganın bu ağa açılan proxy'sine dönüştürür. Özellikle küçük ekiplerde kolayca ortaya çıkar, çünkü buna yol açan özellik zararsız görünür: bir ayarlar sayfasındaki URL alanı, bir sohbetteki önizleme kartı, profil fotoğrafı indiren bir yardımcı fonksiyon. Bunların hiçbiri ağ güvenliği kodu gibi görünmez, bu yüzden nadiren o gözle incelenir.

Saldırganın hedefledikleri

  • En bilineni AWS, GCP ve Azure'daki 169.254.169.254 olan bulut metadata endpoint'leri; bunlar instance ayrıntılarını ve bazı yapılandırmalarda makinenin rolüne ait geçici kimlik bilgilerini döndürebilir.
  • Yalnızca yerel çağrıcılar olduğunu varsayan yönetici arayüzleri, hata ayıklama sunucuları ve metrik endpoint'leri gibi localhost'a bağlı servisler.
  • Dışarıdan erişilebilir olmaları hiç düşünülmediği için kimlik doğrulaması olmayan özel aralıklardaki (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) dahili servisler.
  • Dahili ağı haritalamak için yanıt sürelerini veya hata mesajlarını kullanan port tarama ve servis keşfi.
  • Çekme kütüphanesi file:, gopher: veya ftp: gibi şemaları destekliyorsa HTTP dışı protokoller.

Yanıt saldırgana döndürülmese bile, kör bir SSRF dahili endpoint'lere karşı durum değiştiren istekler tetikleyebilir. Webhook'lar çoğu zaman kördür ve bu onları güvenli yapmaz. Birçok dahili servis, önbellek temizleme veya bir URL arkasındaki yönetici işlemleri gibi durum değiştiren basit GET isteklerini kabul eder; çünkü dışarıdan kimsenin onlara ulaşamayacağını varsayar.

Basit kontroller neden başarısız olur

İlk refleks, URL'yi ayrıştırıp localhost gibi sunucu adlarını veya 10. ya da 192.168. ile başlayan dizeleri reddetmektir. Bu, birkaç nedenle başarısız olur.

  • Sunucu adları IP'lere çözümlenir. Saldırgan, A kaydı 127.0.0.1'i veya 169.254.169.254'ü gösteren bir alan adı kaydeder ve sunucu adı kontrolü hiçbir sorun görmez.
  • IP adreslerinin birçok yazılışı vardır: ondalık (2130706433), onaltılık, 127.1 gibi kısa biçimler ve ::ffff:127.0.0.1 gibi IPv4 eşlemeli IPv6. Dize eşleştirme bunları kaçırır.
  • DNS rebinding: alan adı, siz doğrularken herkese açık bir IP'ye, bir an sonra HTTP istemcisi bağlanırken özel bir IP'ye çözümlenir. Doğrulama ve bağlanma iki ayrı sorgudur.
  • Yönlendirmeler: doğruladığınız URL http://169.254.169.254/ adresine bir 302 döndürür ve HTTP istemcisi size sormadan bu yönlendirmeyi izler.

Ortak nokta, doğrulamanın soketin gerçekte bağlandığı adresten başka bir şey üzerinde yapılmasıdır. Çözüm, çözümlenmiş IP'yi bağlantı anında, yönlendirmelerden sonrakiler dahil her bağlantı için kontrol etmektir.

En güçlüden başlayarak savunmalar

Mümkünse hedefleri izin listesine alın

Özelliğin yalnızca bilinen bir sunucu kümesiyle konuşması gerekiyorsa, örneğin birkaç sağlayıcıyla bir entegrasyonsa, bu sunucu adlarını izin listesine alın ve geri kalan her şeyi reddedin. Bu en güçlü ve üzerinde akıl yürütmesi en kolay kontroldür. Ayrıştırılmış sunucu adını startsWith veya endsWith kontrolleriyle değil, birebir karşılaştırın; bu kontroller api.example.com.attacker.net veya evilexample.com gibi benzer görünen sunucuları kabul eder. Aşağıdakilerin tümü, herkese açık rastgele URL'leri kabul etmek zorunda olan özellikler içindir.

Şemayı ve portu kısıtlayın

Yalnızca https: kabul edin (http:'yi yalnızca mecbursanız). URL içindeki kimlik bilgilerini reddedin ve açık bir ihtiyaç olmadıkça portları 443 ve 80 ile sınırlayın. Ayrıştırma için asla regex değil, standart bir URL ayrıştırıcı kullanın; WHATWG ayrıştırıcısı olağan dışı IP yazılışlarını da normalleştirir, bu da sonraki kontrollere yardımcı olur.

Çözümleyin, ardından IP'yi bağlantı anında kontrol edin

Loopback, özel, link-local, carrier-grade NAT, belirtilmemiş (unspecified) adresleri ve IPv6 unique-local ile link-local aralıklarını engelleyin. En önemlisi, kontrolü HTTP istemcisinin kullandığı DNS sorgusunun içinde uygulayın; böylece doğrulanan adres, bağlanılan adresle aynı olur. Bu, DNS rebinding açığını kapatır. Node'un http ve https modülleri tam da bu amaçla özel bir lookup fonksiyonu kabul eder.

Aşağıdaki engelleme listesi, çoğu dağıtım için önemli olan aralıkları kapsar. Kendi altyapınıza ait herkese açık aralıkları da ekleyin; çünkü herkese açık IP'ye sahip bir load balancer veya dahili API, ağınızın içinden gelen isteklere yine de güvenebilir. Yalnızca ilk adresi değil, çözümlenen adreslerden herhangi biri engelliyse sunucu adını reddedin; çünkü istemci adresleri herhangi bir sırayla deneyebilir.

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

Kolayca gözden kaçan bir ayrıntı: URL bir IP literal içerdiğinde Node, lookup'ı hiç çağırmadan doğrudan bağlanır. Bu yüzden istek fonksiyonu, işi devretmeden önce IP literal'leri kendisi kontrol etmelidir.

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

Node'da undici üzerine kurulu fetch kullanıyorsanız aynı fikir özel bir dispatcher ile uygulanır: connect.lookup seçeneğine sahip bir undici Agent oluşturun ve bunu dispatcher olarak verin. İlke değişmez: kontrol, bağlantının kurulduğu yerde yaşar. Ortamınız giden trafiği bir HTTP proxy üzerinden yönlendiriyorsa, hedef sunucu için DNS sorgusunun bu durumda proxy üzerinde yapıldığını unutmayın; dolayısıyla aynı kuralları proxy'nin kendisi uygulamalıdır.

Yönlendirmeleri kapatın veya her adımı yeniden doğrulayın

Webhook'larda yönlendirmeleri hiç izlemeyin; yönlendirme yapan bir alıcı yanlış yapılandırılmıştır ve açıkça başarısız olmak müşteriye endpoint'ini düzeltmesi gerektiğini söyler. Yönlendirmelerin olağan olduğu bağlantı önizlemeleri ve içe aktarıcılarda ise yönlendirmeleri küçük bir sınırla elle izleyin ve her adımı aynı şema, port ve IP kontrollerinden geçirin. fetch ile redirect: "manual" ayarlayın ve Location başlığını kendiniz işleyin.

Başarılı bir isteğin yapabileceklerini sınırlayın

  • Bağlantı ve toplam zaman aşımları belirleyin ve yanıt boyutunu sınırlayın; böylece çekici, bağlantıları açık tutmak veya büyük dosyalar çekmek için kullanılamaz.
  • Ham yanıtları veya ayrıntılı hata mesajlarını kullanıcıya döndürmeyin. Bağlantı önizlemelerinde yalnızca çıkarılan başlığı, açıklamayı ve görsel URL'sini döndürün.
  • Kendi kimlik doğrulama başlıklarınızı ve çerezlerinizi çıkarın; çekici, dahili kimlik bilgilerini asla kullanıcının verdiği sunuculara göndermemelidir.

Bir egress proxy üzerinden yönlendirin

En sağlam kurulum, politikayı uygulama kodunun dışına taşır. Kullanıcı kaynaklı giden istekleri özel bir egress proxy üzerinden ya da dahili servislere veya metadata endpoint'ine hiçbir rotası olmayan bir ağ segmentindeki izole bir worker'dan çalıştırın. Böylece URL doğrulamasındaki bir hata ihlale dönüşmez, çünkü ağın kendisi isteği reddeder. Açık kaynak forward proxy'ler hedef kurallarını sizin yerinize uygulayabilir; ayrı bir worker ise yavaş veya kötü niyetli yanıtları ana web sürecinizden izole eder.

Metadata servisini sağlamlaştırın

AWS'de, PUT isteğiyle alınan bir oturum token'ı gerektiren IMDSv2'yi zorunlu kılın ve container'ların host üzerinden ona ulaşamaması için hop limitini 1'de tutun. GCP ve Azure, metadata çağrılarında belirli bir istek başlığı gerektirir. Bunlar basit GET tabanlı SSRF için çıtayı ciddi ölçüde yükseltir, ancak link-local adresleri engellemenin yerini tutmaz, ikinci bir katmandır. Ayrıca instance'a veya servis rolüne yalnızca uygulamanın ihtiyaç duyduğu izinleri verin; böylece metadata servisi üzerinden çalınan kimlik bilgileri olabildiğince az şeyin kilidini açar.

Savunmalarınızı test etmek

  • http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ ve http://169.254.169.254/latest/meta-data/ adreslerini deneyin ve her birinin reddedildiğini doğrulayın.
  • Kontrol ettiğiniz bir sunucu adını 127.0.0.1'e yönlendirin ve lookup kontrolünün onu reddettiğini doğrulayın. Ardından biri herkese açık, biri özel iki A kaydı verin ve yine reddedildiğini doğrulayın.
  • Herkese açık bir sunucudan özel bir adrese 302 yönlendirmesi sunun ve izlenmediğini doğrulayın.
  • file:, ftp: ve gopher: URL'lerini, kimlik bilgisi veya olağan dışı port içeren URL'leri deneyin.

Kod incelemesinde, istek URL'sinin girdiden oluşturulduğu her yeri arayın: fetch, axios, got, requests, http.Get, headless tarayıcı page.goto çağrıları ve URL kabul eden görsel işleme kütüphaneleri. CodeAuditAgent bu veri akışını herkese açık depolarda ve yapıştırılmış kod parçalarında izler ve SSRF'yi alıntılanmış çağrı noktası, istismar senaryosu ve bir yamayla birlikte CWE-918 olarak raporlar.