omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

GüvenlikWeb

Satır içi ilk boyamayla katı bir içerik güvenlik politikası

Kritik CSS'i ve küçük bir script'i satır içi gömmek ilk boyama için iyidir, katı CSP için yasaktır. Çözüm hash, ama doğru anda hesaplanırsa.

Kritik CSS'i ve küçük bir script'i sayfanın içine gömmek, ilk boyama için yapılabilecek en ucuz iyileştirmedir. Tek istek, ek gidiş dönüş yok, HTML gelir gelmez ekranda metin. Sonra aynı siteye katı bir içerik güvenlik politikası konur ve sayfa stilsiz açılır, konsol da satır içi stilin bir direktifi ihlal ettiği için uygulanmadığını tekrarlayıp durur. İki karar da doğrudur. Sadece birbirleriyle tanıştırılmaları gerekir.

Gerçekte ne oluyor

İçerik güvenlik politikası, içeriğin nereden gelebileceğini listeler. Politikada bir style-src veya script-src direktifi varsa ve bu direktif 'unsafe-inline' içermiyorsa, tarayıcı doğrudan belgenin içine yazılmış hiçbir şeyi uygulamaz veya çalıştırmaz. Direktifin bütün amacı budur: markup'ınıza <script> etiketi sokmayı başaran saldırgan da aynı reddi alır.

Kendi satır içi içeriğinizi geri açmanın üç yolu var.

  1. 'unsafe-inline', yani sizinkine de saldırganınkine de eşit izin. Bu, politikayı koyma sebebinizi ortadan kaldırır.
  2. Nonce, yani her yanıtta üretilen ve etikette tekrarlanan rastgele değer. Tarayıcı, nonce niteliği politikadakiyle eşleşen bloğa izin verir.
  3. İçeriğin kendisinin hash'i, politikaya 'sha256-...' olarak yazılır. Tarayıcı her satır içi bloğu hash'ler ve tanıdıklarına izin verir.

Nonce modern cevap gibi durur, dinamik yanıtlar için de öyledir. Statik sayfada ise dekordan ibarettir. Diskteki dosya herkese aynı byte'ları verir, dolayısıyla nonce build sırasında dosyaya gömülür ve sayfa kaynağından okunabilir. Sabit bir nonce, birkaç fazladan adımla süslenmiş 'unsafe-inline'tır. Sayfa istek başına render ediliyorsa nonce'u düzgün üretebilirsiniz, o zaman doğru araç odur.

İstek başına değişmeyen sayfaya uyan araç hash'tir. Tarayıcı, açılış ve kapanış etiketi arasındaki karakterlerin SHA-256 değerini hesaplar, base64'e çevirir ve listeyle karşılaştırır. Etiketlerin kendisi hash'lenen içeriğe dahil değildir, dışarıdaki hiçbir şey de öyle. Bu eşleşme birebirdir: bloğun sonundaki fazladan tek bir satır sonu bile farklı bir hash demektir.

İleride çok vakit kazandıran iki ayrıntı var.

  • type değeri application/ld+json gibi olan script öğesi bir veri bloğudur. Tarayıcı onu çalıştırmak üzere hiç hazırlamaz, dolayısıyla satır içi script kontrolü ona uygulanmaz. Yapısal veri, katı bir politikada hiçbir hash olmadan çalışır.
  • Satır içi olay işleyicileri ve style nitelikleri ayrı bir kategoridir. Düz bir hash onları kapsamaz, izin vermek için 'unsafe-hashes' gerekir. Bu da genelde o işleyicinin script dosyanızda olması gerektiğinin iyi bir işaretidir.

Politikada style-src hiç yazmıyorsa default-src devreye girer. Yani default-src 'none' ile başlayan bir politika, satır içi stili de dışarıdaki stil dosyasını da aynı anda kapatır. Başlangıç noktası olarak bu iyidir: sayfanın gerçekten neye ihtiyacı olduğu konsolda tek tek dökülür ve politikaya yalnızca kanıtlanmış ihtiyaçları eklersiniz. Tersini yapıp geniş bir politikayı zamanla daraltmaya çalışırsanız, hangi satırın hangi özellik için orada olduğunu birkaç ay içinde kimse bilemez.

Nasıl görülür

Konsol hangi direktifin neyi reddettiğini söyler, ama işe yarayan kontrol kaynak kodda değil yayındaki byte'lar üzerinde yapılır. Satır içi bloğu canlı sayfadan çekip tarayıcının yaptığı gibi hash'leyin:

curl -s https://ornek.com/ \
  | perl -0777 -ne 'print $1 while /<style>(.*?)<\/style>/gs' \
  | openssl dgst -sha256 -binary | openssl base64
# 4Vp0Xg6Oq1lWQ2oR8pKb1Jm3nS7uYfD5cE9tGh2iZxQ=

curl -sI https://ornek.com/ | grep -i content-security-policy
# content-security-policy: default-src 'none'; style-src 'sha256-...'

İlk komuttan çıkan değer ikincisinde geçmiyorsa hatayı tahmin etmeden bulmuşsunuz demektir. İkisi eşleşiyorsa ve tarayıcı hâlâ reddediyorsa, etiketler arasındaki içeriğe curl'de değil tarayıcıda bakın: aradaki farkı genelde bir kenar katmanının veya proxy'nin eklediği parça yaratır.

Çözüm

Hash'leri, byte'lara dokunabilen bütün adımlar bittikten sonra, yayınlanacak son dosya üzerinden hesaplayın. Render eden, minify eden ve sonra dosyayı yazan bir build'de sıra şudur: render, minify, hash, yaz.

import { createHash } from 'node:crypto';

const sha = (s) => "'sha256-" + createHash('sha256').update(s, 'utf8').digest('base64') + "'";

const html = await minifyHtml(render(page), options);
const inline = [...html.matchAll(/<(script|style)>([\s\S]*?)<\/\1>/g)];
const script = inline.filter((m) => m[1] === 'script').map((m) => sha(m[2]));
const style = inline.filter((m) => m[1] === 'style').map((m) => sha(m[2]));

Buradaki düzenli ifade yalnızca niteliksiz etiketleri yakalar, yani tam olarak hash gereken kümeyi. <script type="application/ld+json"> bloğunun niteliği olduğu için listeye girmez. Bu şans değil, istenen davranıştır.

Politikanın kendisi hiçbir şeyden başlar ve yalnızca sayfanın ihtiyacını kanıtladığı şeyleri ekler:

default-src 'none';
base-uri 'none';
img-src 'self' data:;
font-src 'self';
style-src 'sha256-4Vp0Xg...';
script-src 'sha256-9Kd2Rz...';
connect-src 'self';
form-action 'none';
frame-ancestors 'none';
upgrade-insecure-requests

connect-src 'self' satırı, bir saate mal olan bir rapor yüzünden orada. Denetim aracı sitenin robots.txt dosyasına ulaşılamadığını söylüyordu, oysa curl dosyayı anında getiriyordu ve dosya yerli yerindeydi. Araç sayfanın içinde çalışıyor ve dosyayı o belgeden bir fetch ile istiyor, connect-src olmayan default-src 'none' ise bu isteği reddediyor. Araç başarısız isteği görüp dosyaya ulaşılamıyor diye yazdı. Dosyayla ilgili hiçbir sorun yoktu, politika sayfanın bir şey sormasına izin vermiyordu.

Son parça build'in içindeki bekçi, çünkü bu tür hatalar sessizce yayına gider:

for (const [file, out] of pages) {
  const blocks = [...out.matchAll(/<(script|style)>([\s\S]*?)<\/\1>/g)].map((m) => m[2]);
  for (const b of blocks) {
    if (!csp.includes(sha(b))) throw new Error(`CSP hash uyuşmuyor: ${file}`);
  }
}

Böylece sonradan eklenen bir minifier, değişen bir boşluk ayarı veya style bloğunun içine yorum basmaya başlayan bir şablon, siteyi değil build'i düşürür.

Çalıştığını nasıl doğrularsınız

Canlı başlıkla canlı byte'ları tek komutta karşılaştırın ve size tek kelimeyle cevap versin:

p=$(curl -sI https://ornek.com/ | tr 'A-Z' 'a-z' | sed -n 's/^content-security-policy: //p')
h=$(curl -s https://ornek.com/ | perl -0777 -ne 'print $1 while /<style>(.*?)<\/style>/gs' \
     | openssl dgst -sha256 -binary | openssl base64)
case "$p" in *"sha256-$h"*) echo tamam ;; *) echo "uyusmuyor: $h" ;; esac
# tamam

Sonra sayfayı açın ve konsolun "neredeyse boş" değil, boş olduğunu görün. Kalan tek ihlal genelde kalan tek satır içi niteliktir. Görsel kontrol de önemli: stilsiz gelen ilk boyama ile gizlenmiş ilk boyama aynı başarısızlıktır, doğru gelen ve kendini animasyonun arkasına saklamayan bir sayfa zaten satır içi gömmenin sebebidir.

Nelere dikkat etmeli

  • Hash alındıktan sonra HTML'i düzenleyen her şey sayfayı bozar. Minifier'lar, kenar işçileri, enjekte edilen etiketler ve boşluk normalleştiren proxy'ler bu gruba girer. Hash'i gerçekten yayınladığınız dosyadan alın.
  • Bir direktifte hash veya nonce varsa, bunları destekleyen tarayıcılar aynı direktifteki 'unsafe-inline' değerini yok sayar. Onu "yedek olsun" diye bırakmak hiçbir işe yaramaz, sadece politikayı okuyan bir sonraki kişiyi yanıltır.
  • Meta öğesi frame-ancestors, report-uri, report-to ve sandbox değerlerini ifade edemez, bu değerler orada yok sayılır. Yani clickjacking koruması başlıktan gelmek zorundadır.
  • Meta politikası yalnızca ayrıştırıcının ona ulaştığı noktadan sonra geçerlidir, bu yüzden head'in en başında durmalıdır. Hem başlık hem meta varsa iki politika da uygulanır ve sonuç kesişimleri olur. Unutulmuş bir etiketi öğrenmenin epey kafa karıştırıcı bir yoludur.
  • Satır içi SVG ikonlar için img-src içine data: eklemek makul, aynı değeri script-src içine eklemek değil. Orada data: yazmak, adresten kod çalıştırmanın kapısını yeniden açar ve politikayı koyma sebebinizi büyük ölçüde siler.
  • Başlığın sunucu yapılandırmasından sağ çıkması gerekir. Tek bir location bloğunun içine başlık eklemek, üstten miras alınan bütün başlıkları düşürebilir. Build'i suçlamadan önce bu tuzağı bilmekte fayda var.

Katı politika ile hızlı ilk boyama, yalnızca build ikisinden de habersizken ters yönlere çeker. Kalıcı çözüm politikayı birinin elle düzenlediği bir dosya değil, build'in çıktısı olarak görmektir: byte'ları üreten adım hash'leri de üretir ve içeriğiyle politikası çelişen bir sayfayı bekçi yayına bırakmaz. Aynı düşünce head'in geri kalanı için de geçerli; fontu alt kümeleyip preload etmek de elle bakılan bir listeye değil build'e aittir. Bir güvenlik kontrolü koruduğu şeyden üretildiğinde, ondan uzaklaşmayı bırakır.

Sorular ve cevaplar

Statik sitede CSP nonce kullanabilir miyim?
Anlamlı biçimde kullanamazsınız. Nonce tahmin edilemez olmalı ve her yanıtta değişmelidir, diskten servis edilen statik dosya ise herkese aynı byte'ları verir. Build sırasında dosyaya gömülen nonce, sayfa kaynağından okunabilen bir sabittir ve unsafe-inline ile aynı anlama gelir. İstek başına değişmeyen içerik için doğru mekanizma hash'tir.
JSON-LD script bloğu için CSP hash gerekir mi?
Gerekmez. type değeri application/ld+json gibi olan script öğesi veri bloğu kabul edilir ve hiçbir zaman çalıştırılmak üzere hazırlanmaz, bu yüzden satır içi script kontrolü ona hiç uygulanmaz. unsafe-inline içermeyen bir politikada yapısal veri eklemek sorun çıkarmaz. Engelleniyorsa önce type niteliğinin gerçekten orada olup olmadığına bakın.
Sayfa neden canlıda stilsiz, lokalde düzgün görünüyor?
Neredeyse her zaman hash hesaplandıktan sonra byte'ları değiştiren bir adım vardır. Deploy adımındaki minifier, boşlukları sıkıştıran bir proxy ya da kenarda enjekte edilen bir etiket, hash'in artık eşleşmediği bir içerik üretir. Hash'i yayınlanan son dosya üzerinden hesaplayın ve politikada karşılığı olmayan satır içi blok varsa build'i düşürün.
CSP'nin meta etiketi hali yeterli mi?
Style ve script kaynakları için iş görür ama gerçek sınırları vardır. Meta öğesi frame-ancestors, report-uri, report-to ve sandbox değerlerini ifade edemez, ayrıca yalnızca ayrıştırıcının onu gördüğü noktadan itibaren geçerlidir. Politikayı yanıt başlığı olarak gönderin, meta etiketini de başlık ayarlayamadığınız ortamlar için yedek olarak düşünün.