Content Security Policy ile satır içi betiği sha256 özetiyle güvenle çalıştırmak
unsafe-inline açmadan tek bir satır içi betiğe izin vermek: özeti sunucuda hesaplamak, başlığa eklemek ve testle kilitlemek.
Content Security Policy (CSP), tarayıcıya "bu sayfada hangi kaynaktan gelen betik, stil, görsel ve çerçeve çalışabilir" sorusunun cevabını veren bir HTTP başlığıdır. Doğru kurulduğunda siteler arası betik çalıştırma (XSS) açıklarının etkisini büyük ölçüde azaltır: saldırgan sayfaya bir <script> parçası sokmayı başarsa bile tarayıcı onu çalıştırmaz. Sorun, gerçek hayattaki sitelerin neredeyse hiçbirinin tamamen dış dosyalardan oluşmamasıdır. Bir analitik etiketi, bir çerez onayı varsayılanı ya da küçük bir yapılandırma satırı çoğu zaman sayfanın içine, satır içi betik olarak yazılmak zorundadır.
Bu noktada çoğu ekip kestirme yolu seçer ve politikaya 'unsafe-inline' ekler. Bu, CSP'nin XSS'e karşı sağladığı korumanın en değerli kısmını kapatmak demektir. Bu yazıda daha dar bir yolu anlatıyoruz: satır içi betiğin içeriğinin SHA-256 özetini hesaplayıp yalnız o içeriğe izin vermek. Örnekler, bu sitenin kendi kodundan, Google Consent Mode v2 varsayılanını sayfanın en başında satır içi çalıştırmamız gereken durumdan geliyor.
Neden satır içi betik gerekiyor?
Consent Mode v2'de reklam ve analitik izinlerinin varsayılan değeri, herhangi bir Google etiketi yüklenmeden önce bildirilmelidir. Google'ın Consent Mode belgesi varsayılan komutun sayfadaki her etiketten önce çalışmasını ister; bunu en güvenle sağlayan yol, <head> bölümünün başındaki satır içi bir parçacıktır. Bunu dış bir dosyayla da yapabilirsiniz, ancak sıralama o zaman dosyanın yüklenmesine bağlı kalır. Dış dosya, ağ gecikmesi ya da önbellek hatası yüzünden geç yüklenirse varsayılan da gecikir. Bu yüzden birkaç satırlık, içeriği hiç değişmeyen bir betiği sayfaya gömmek mantıklı bir tercih. İçeriğin sabit olması, özet yönteminin anahtarıdır.
Özet (hash) yöntemi nasıl çalışır?
CSP Level 2'den beri script-src yönergesi, 'sha256-…', 'sha384-…' ya da 'sha512-…' biçiminde kaynak ifadeleri kabul eder. Tarayıcı, sayfadaki her satır içi <script> öğesinin metnini alır, seçilen algoritmayla özetini hesaplar, sonucu Base64'e çevirir ve politikadaki listeyle karşılaştırır. Eşleşen betik çalışır, eşleşmeyen engellenir ve konsola bir ihlal kaydı düşer.
Burada iki ayrıntı kritik:
- Özet,
<script>ve</script>etiketleri arasındaki metnin tamamı üzerinden hesaplanır. Baştaki bir boşluk, sondaki bir satır sonu ya da farklı bir tırnak işareti özeti değiştirir. - Metin, sayfanın karakter kodlamasıyla (pratikte UTF-8) bayt dizisine çevrilip özetlenir. Türkçe karakter içeren bir yorum satırı bile hesaba katılır.
Bu yüzden özeti elle bir kez hesaplayıp başlığa yapıştırmak kırılgandır. Betiği biri biçimlendirir, bir boşluk ekler ve betik sessizce engellenmeye başlar. Daha sağlam yol, betik metnini ve özetini aynı kaynaktan, kod içinde üretmektir.
Betiği ve özetini tek kaynaktan üretmek
Python tarafında betiği bir sabit olarak tutuyor, özetini modül yüklenirken hesaplıyoruz. Şablon bu sabiti olduğu gibi basıyor, CSP üreten kod da aynı modülden özeti alıyor. Böylece metin değişirse özet de kendiliğinden değişiyor.
import base64
import hashlib
ONAY_VARSAYILAN_JS = (
"window.dataLayer=window.dataLayer||[];"
"function gtag(){dataLayer.push(arguments);}"
"gtag('consent','default',{ad_storage:'denied',ad_user_data:'denied',"
"ad_personalization:'denied',analytics_storage:'denied',wait_for_update:500});"
)
def csp_ozeti(metin: str) -> str:
ozet = hashlib.sha256(metin.encode("utf-8")).digest()
return "'sha256-" + base64.b64encode(ozet).decode("ascii") + "'"
ONAY_VARSAYILAN_OZET = csp_ozeti(ONAY_VARSAYILAN_JS)
Betiği tek satırlık, boşluksuz bir metin olarak tutmak bilinçli bir tercih: şablonda <script> açılışı ile metin arasında, metin ile kapanış arasında hiçbir boşluk bırakmıyoruz. Okunabilirlik için kodda parçalara bölmek sorun değil; Python bu parçaları derleme anında tek bir metne birleştirir.
Şablon motorunun kaçış tuzağı
Jinja gibi şablon motorları, değişkenleri HTML'ye basarken otomatik kaçış uygular. Bu, XSS'e karşı çok değerli bir varsayılandır; ancak bizim durumumuzda betiği bozar. Yukarıdaki metindeki tek tırnaklar ' biçimine dönüşür. Tarayıcı <script> içinde HTML varlıklarını çözmediği için JavaScript sözdizimi hatası verir; üstelik özetlenen metin artık Python'un özetlediği metin değildir.
Bu yüzden sabiti şablona kaçışsız basıyoruz. Bunu yalnız içeriği tamamen bizim kontrolümüzde olan, kullanıcı girdisi taşımayan bir sabit için yapıyoruz; kullanıcıdan gelen herhangi bir değeri kaçışsız basmak tam da CSP'nin önlemeye çalıştığı açığı yaratır.
<head>
<meta charset="utf-8">
<script>{{ onay_varsayilan_js|safe }}</script>
<script src="/static/js/google-etiket.js"></script>
...
</head>
Özeti başlığa eklemek
CSP başlığını genellikle tek bir yerde, bir after_request kancasında üretiyoruz. Politika birden çok yönerge içerebildiği için, var olan script-src yönergesine özeti eklemek, yoksa yeni bir yönerge oluşturmak gerekir. Basitleştirilmiş hâli şöyle:
def script_src_ekle(csp: str, *ifadeler: str) -> str:
yonergeler = [p.strip() for p in csp.split(";") if p.strip()]
for i, yonerge in enumerate(yonergeler):
parcalar = yonerge.split()
if parcalar[0] == "script-src":
parcalar += [x for x in ifadeler if x not in parcalar]
yonergeler[i] = " ".join(parcalar)
return "; ".join(yonergeler)
yonergeler.append(" ".join(("script-src", "'self'") + ifadeler))
return "; ".join(yonergeler)
@app.after_request
def guvenlik_basliklari(yanit):
csp = "default-src 'self'; object-src 'none'; base-uri 'self'"
yanit.headers["Content-Security-Policy"] = script_src_ekle(csp, ONAY_VARSAYILAN_OZET)
return yanit
Dikkat edilmesi gereken bir nokta: script-src yoksa tarayıcı default-src değerine düşer. Yeni bir script-src yönergesi eklediğinizde artık default-src betikler için geçerli olmaz; bu yüzden yeni yönergeye 'self' ve kullandığınız dış betik kaynaklarını da açıkça yazmanız gerekir.
Dürüst bir not ekleyelim: bu sitede bugün yayında olan politika, betik kaynaklarını henüz kısıtlamıyor; yalnız çerçeveye alınmayı, <object> kullanımını ve <base> kaçırmayı kapatıyor. Özet ekleyen fonksiyon ise politika bir script-src içerdiği anda devreye girecek şekilde hazır duruyor. Böylece betik kaynaklarını sıkılaştırdığımız gün satır içi varsayılan bozulmayacak.
Özet ile 'unsafe-inline' birlikte olursa
Eski tarayıcılarla uyum için bazı ekipler politikaya hem özeti hem de 'unsafe-inline' ifadesini koyar. CSP Level 2 ve sonrasını destekleyen tarayıcılar, listede bir özet ya da nonce gördüğünde 'unsafe-inline' ifadesini yok sayar; çok eski tarayıcılar ise özeti tanımadığı için 'unsafe-inline' sayesinde betiği çalıştırır. Bu, geriye dönük uyum için bilinen bir yöntemdir; ancak modern tarayıcılarda koruma yalnız özetten gelir, eski tarayıcılarda ise koruma yoktur. Hedef kitlenizi bilerek karar verin.
Nonce mu, özet mi?
Satır içi betiğe izin vermenin diğer yolu nonce kullanmaktır: her istekte rastgele bir değer üretir, hem başlığa hem de <script nonce="…"> özniteliğine yazarsınız. Nonce, içeriği istekten isteğe değişen betikler için uygundur; ancak sayfanın önbelleğe alınmasını zorlaştırır, çünkü her yanıtın farklı bir değer taşıması gerekir. İçeriği sabit olan betiklerde özet yöntemi daha sade kalır: yanıt önbelleğe alınabilir, özet yalnız kod değişince değişir.
Pratik kuralımız şu: içerik sabitse özet, içerik isteğe göre değişiyorsa nonce. İkisini aynı politikada birlikte kullanmak da mümkündür.
Testle kilitlemek
Özetin doğru olduğunu tarayıcıda gözle kontrol etmek yeterli değildir; bir sonraki düzenleme her şeyi bozabilir. Bu yüzden sayfayı test istemcisiyle çekip içindeki satır içi betiklerin özetini yeniden hesaplayan, sonra bu özetlerin başlıkta olup olmadığına bakan bir test yazıyoruz:
import base64
import hashlib
import re
def test_satir_ici_betikler_csp_ozetinde(client):
yanit = client.get("/")
html = yanit.get_data(as_text=True)
csp = yanit.headers.get("Content-Security-Policy", "")
betikler = re.findall(r"<script>(.*?)</script>", html, re.S)
assert betikler, "satır içi betik bulunamadı"
for metin in betikler:
ozet = base64.b64encode(hashlib.sha256(metin.encode("utf-8")).digest()).decode()
assert f"'sha256-{ozet}'" in csp
Düzenli ifade yalnız özniteliksiz <script> etiketlerini yakalar; src özniteliği olan dış betikler ve type="application/ld+json" veri blokları kapsam dışında kalır. JSON-LD blokları çalıştırılabilir betik değil veri bloğu olduğu için script-src tarafından engellenmez; yine de kendi politikanızda bunu tarayıcıda doğrulamanızı öneririz.
Yayına almadan önce: Report-Only
Sıkı bir politikayı doğrudan devreye almak, gözden kaçan bir betiği canlıda bozabilir. Bunun için Content-Security-Policy-Report-Only başlığı vardır: tarayıcı politikayı uygulamaz, yalnız ihlalleri raporlar. Birkaç gün bu modda çalıştırıp tarayıcı konsolundaki ve rapor ucundaki ihlalleri incelemek, gerçek politikaya geçmeden önce en güvenli adımdır. Özet listesini komut satırından doğrulamak isterseniz şu komut, bir dosyadaki betik metninin özetini verir:
printf '%s' "$(cat betik.js)" | openssl dgst -sha256 -binary | openssl base64
Burada printf '%s' kullanımı önemli: echo sona bir satır sonu ekler ve özeti değiştirir. Komut ikamesi $(…) ise dosyanın sonundaki satır sonlarını kırpar; bu, şablona sonunda satır sonu olmadan basılan bir metin için doğru sonucu verir. Hangi yolu seçerseniz seçin, şablona basılan metinle bayt bayt aynı içeriği özetlediğinizden emin olun.
Sonuç
Satır içi betiğe izin vermek için 'unsafe-inline' açmak zorunda değilsiniz. İçeriği sabit olan bir betiği kodda tek bir sabit olarak tutmak, özetini aynı yerde hesaplamak, şablonda kaçışsız ama başka hiçbir değişken karıştırmadan basmak ve bütün bunları bir testle kilitlemek; hem güvenli hem de bakımı kolay bir düzen sağlar. Çerez onayı gibi hukuki önemi olan bir varsayılanın sayfanın en başında, gecikmesiz çalışması da bu sayede mümkün olur. Çerez onayı tarafının ayrıntılarını Consent Mode v2 yazımızda anlattık.
Benzer bir sistem mi kuruyorsunuz?
İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.
Bize yazın