İçeriğe atla
md ~/yazilar/proxy-arkasinda-gercek-ip-x-forwarded-for
Atatürk'ü Saygı ve Özlemle Anıyoruz

10 Kasım · 88. yıl dönümü

Atatürk'ü Saygı ve Özlemle Anıyoruz

Cumhuriyetimizin kurucusu Mustafa Kemal Atatürk, 10 Kasım 1938'de saat 09.05'te İstanbul'da, Dolmabahçe Sarayı'nda hayatını kaybetti. Her yıl bu saatte bütün ülkede saygı duruşunda bulunuyoruz.

Bize emanet ettiği Cumhuriyeti yaşatmak hepimizin görevi.

Benim naçiz vücudum elbet bir gün toprak olacaktır, fakat Türkiye Cumhuriyeti ilelebet payidar kalacaktır.Mustafa Kemal Atatürk
Tüm yazılar

· 5 dk okuma

Proxy Arkasında Gerçek İstemci IP'si: X-Forwarded-For ve Çalışmayan Rate Limit

Proxy Arkasında Gerçek İstemci IP'si: X-Forwarded-For ve Çalışmayan Rate Limit

Bir web uygulamasında IP başına istek sınırı (rate limit) koymak kolay görünür: isteğin geldiği adresi al, dakikada kaç istek yaptığını say, sınırı aşana 429 dön. Uygulama bir proxy'nin arkasında çalışıyorsa ilk adım, yani "isteğin geldiği adres", sanıldığı kadar basit değil. Bu yazıda iki projemde canlı sitede çalışmayan iki rate limit'i anlatıyorum. Birinde sınır hiç dolmuyordu, diğerinde sahte bir başlıkla tamamen atlatılabiliyordu. İkisinin kökü de aynıydı: X-Forwarded-For başlığının hangi değerine güvenileceği.

Proxy arkasında "istemci adresi" ne demek?

Ücretsiz katmanda yayınladığım sunuculu projelerin çoğu Render'da çalışıyor. Render'ın önünde Cloudflare var. Bir ziyaretçinin isteği uygulamaya şu yoldan ulaşıyor:

ziyaretçi  →  Cloudflare sunucusu  →  Render proxy'si  →  uygulama

Uygulamanın gördüğü bağlantı ziyaretçiden değil, Render'ın proxy'sinden geliyor. Ziyaretçinin adresini taşımak için proxy'ler X-Forwarded-For başlığını kullanıyor. Her proxy, isteği kimden aldıysa onun adresini listenin sonuna ekliyor:

X-Forwarded-For: <ziyaretçinin yazdığı>, <ziyaretçi>, <Cloudflare sunucusu>

Bu listenin iki özelliği var:

  1. En soldaki değerler istemcinin elinde. Ziyaretçi isteği gönderirken kendi X-Forwarded-For başlığını yazabilir. Proxy'ler bu başlığı silmiyor, sadece sonuna ekliyor.
  2. Sağdan saydıkça güvenilir değerlere ulaşılır, ama sadece bilinen proxy sayısı kadar. Hangi proxy'nin listeye ne eklediğini bilmeden hiçbir değere güvenilmez.

Hata 1: Sınır hiç dolmuyordu (Crypto Toolkit, ASP.NET Core)

Crypto Toolkit'te IP başına dakikada 300 istek sınırı koymuştum. Render'ın proxy'si arkasında doğru adresi almak için ASP.NET Core'un ForwardedHeaders ara katmanını açtım. Proxy'nin adresini önceden bilmediğim için bilinen proxy listesini temizledim:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownIPNetworks.Clear();
    options.KnownProxies.Clear();
});

Bu ayarla ara katman, listenin en sağındaki değeri istemci adresi sayıyor. Yerelde ve testlerde her şey doğru çalıştı. Yayından önceki son taramada canlı siteye 320 paralel istek gönderdim ve bir tane bile 429 almadım.

Sebep: en sağdaki değer ziyaretçi değil, isteği Render'a ileten Cloudflare sunucusuydu. Cloudflare'in çok sayıda sunucusu var ve aynı ziyaretçinin istekleri farklı sunuculardan geçebiliyor. Her Cloudflare adresi ayrı bir sayaç açtığı için hiçbir sayaç sınıra ulaşmıyordu. Rate limit kodda vardı ama canlıda yoktu.

Hata 2: Sahte başlıkla atlatılıyordu (KVKK Belge Asistanı, Python)

KVKK Belge Asistanı FastAPI ile yazıldı ve uvicorn ile çalışıyor. Uygulamanın ürettiği linkler https olsun diye uvicorn'un proxy başlıklarını okumasını açtım ve her proxy'ye güvenmesini söyledim:

uvicorn app.main:app --proxy-headers --forwarded-allow-ips='*'

Bütün adresler "güvenilir proxy" sayılınca uvicorn listenin en solundaki değeri istemci adresi yaptı. O değer ise ziyaretçinin kendi yazdığı değerdi. Son taramada canlı siteye her istekte farklı, uydurma bir X-Forwarded-For göndererek denedim: 10 dakikada 20 belgelik sınıra rağmen 22 isteğin 22'si geçti. Her istek kendini başka biri gibi gösterebiliyordu.

Bu iki hata birbirinin tersi gibi görünüyor: biri en sağdaki değere, diğeri en soldakine güveniyordu. Ortak nokta, ikisinde de listeye kimin ne yazdığını bilmeden bir değer seçmemdi.

Ölçmeden düzeltmedim

Hangi başlığın neyi taşıdığını tahmin etmek yerine, KVKK projesine geçici bir tanı adresi ekledim. Adres, isteğin uygulamaya ulaştığı andaki bütün IP ile ilgili başlıkları gösteriyordu. Canlı sitede kendi bağlantımdan, sahte başlıkla ve sahte başlıksız istekler göndererek şunu gördüm:

  • X-Forwarded-For: en solda benim yazdığım değer, sonra gerçek adresim, en sağda bir Cloudflare sunucusu.
  • CF-Connecting-IP: her zaman gerçek adresim.

Cloudflare, ziyaretçinin gerçek adresini CF-Connecting-IP başlığına kendisi yazıyor. Ziyaretçi bu başlığı kendisi göndermeye çalışırsa Cloudflare isteği 403 hatasıyla reddediyor; bunu da canlıda denedim. Yani bu kurulumda taklit edilemeyen tek değer bu başlıktı.

Çözüm

Rate limit için CF-Connecting-IP okunuyor, yoksa (yerelde çalışırken) doğrudan bağlantının adresi:

public static class ClientIp
{
    public const string CloudflareHeader = "CF-Connecting-IP";

    public static string Of(HttpContext context)
    {
        var header = context.Request.Headers[CloudflareHeader].ToString();
        if (IPAddress.TryParse(header, out var address)) return address.ToString();
        return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    }
}

Python tarafı da aynı mantıkta:

def client_ip(request: Request) -> str:
    cf = request.headers.get("cf-connecting-ip", "").strip()
    if cf:
        return cf
    return request.client.host if request.client else "unknown"

uvicorn'daki --proxy-headers ayarını kapatmadım, çünkü kapatınca uygulamanın ürettiği linkler http oluyordu. O ayar artık sadece protokol için kullanılıyor; rate limit X-Forwarded-For'a hiç bakmıyor.

Düzeltmeden sonra aynı denemeleri canlıda tekrarladım. KVKK'da sahte başlıklar işe yaramadı ve 21. istek 429 aldı. Aynı düzeltmeyi Render ve Cloudflare arkasında çalışan Casus ve Sahaf'a da uyguladım, Casus'ta da canlıda denedim.

Başka bir kurulum, başka bir doğru cevap

CF-Connecting-IP her yerde doğru cevap değil, sadece önünde Cloudflare olan kurulumda doğru. Çerezsiz analitik aracım Nabız, Oracle Cloud'da kendi sunucumda Caddy'nin arkasında çalışıyor. Orada önde Cloudflare yok ve Caddy, isteği aldığı adresi listenin sonuna ekliyor. Bu yüzden Nabız'da kural farklı ve iki ayrı adres var:

// clientIP: Caddy arkasındaysak (-trust-proxy) X-Forwarded-For'un son değeri,
// yani Caddy'nin kendi eklediği adres; değilse doğrudan bağlantının adresi.
// İstemcinin kendi yazdığı sahte değerler listenin başında kalır, onlara bakmayız.
  • Hız sınırı için son değer: Caddy'nin eklediği, taklit edilemeyen adres.
  • Tekil ziyaretçi sayımı için ilk değer: Nabız'ın betiği bir sitenin kendi alan adından, örneğin Vercel üzerinden sunulduğunda son değer Vercel'in adresi olur ve bütün ziyaretçiler tek kişi sayılır. İlk değer taklit edilebilir, ama bunun en kötü sonucu sayının biraz şişmesi; kimsenin verisi sızmaz, sınır da atlatılmaz.

Aynı makinede Zarf da Caddy'nin arkasında çalışıyor. X-Forwarded-For'u koruyan Caddy ayarını sadece Nabız'ın bloğuna yazdım; genel yazsaydım Zarf'ın adres okuması da değişecekti.

Öğrendiklerim

  • X-Forwarded-For bir liste ve sol tarafı istemcinin elinde. "Bütün proxy'lere güven" anlamına gelen ayarlar (--forwarded-allow-ips='*', KnownProxies.Clear()) bu listeden yanlış değeri seçtirir.
  • Doğru değer kuruluma bağlı. Önde Cloudflare varsa onun yazdığı başlık, önde kendi proxy'm varsa onun eklediği son değer. Kaç proxy olduğunu ve her birinin ne yazdığını bilmeden kod yazılmaz.
  • Bu hata yerelde görünmez. Yerelde proxy yok; testler de doğrudan uygulamaya bağlanıyor. İki hatayı da yayından önceki son taramada canlı siteye istek göndererek buldum. O günden beri her projenin son taramasında rate limit'i canlıda bir kez sahte başlıkla, bir kez paralel isteklerle deniyorum.