omeryanbas.com

Ömer Yanbaş

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

WebGüvenlik

Güvenlik başlıklarınızı sessizce düşüren nginx kuralı

nginx'te add_header yalnızca alt seviyede hiç add_header yoksa miras alınır. Bir location'a eklenen tek cache başlığı HSTS ve nosniff'i oradan siler.

Bir güvenlik taraması kısa bir liste döner: sitedeki stil dosyaları ve script'ler X-Content-Type-Options olmadan, içerik güvenlik politikası olmadan ve HSTS olmadan sunuluyor. Sayfaların kendisi temiz. Config'de başlıklar olması gerektiği gibi bir kez server seviyesinde yazılmış ve nginx -t hiç şikâyet etmiyor. Yazım hatası da yok, yorum satırına alınmış bir şey de. Başlıkların o path'lerde yok olmasının sebebi, çoğu kişinin ilk kez böyle bir günde tanıştığı bir miras kuralıdır.

Gerçekte ne oluyor

nginx bu davranışı tek cümleyle belgeliyor: add_header satırları üst seviyeden yalnızca bulunulan seviyede hiç add_header yoksa miras alınır. Kural "listeleri birleştir" değil. Seviye başına ya hep ya hiç.

Seviyeler http, sonra server, sonra location ve location içindeki bir if bloğu da kendi başına bir seviye sayılır. Yani gayet makul görünen bir config başlık kaybediyor olabilir:

server {
  listen 443 ssl;
  server_name ornek.com;

  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header Content-Security-Policy "default-src 'self'" always;

  location / {
    try_files $uri $uri/ =404;
  }

  location ~* \.(css|js|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }
}

Asset location'ı tek bir add_header tanımlıyor, dolayısıyla server seviyesindeki dört satır oraya hiç ulaşmıyor. Artık her stil dosyası, her script ve her font yalnızca bir cache başlığıyla çıkıyor. Bir JavaScript dosyasında nosniff eksikliği tam olarak o başlığın var olma sebebidir ve bu dosyalar genelde başkasının da yazabildiği bir dizinden sunulur.

Kuralın ikinci yarısı always parametresi. O olmadan nginx başlığı yalnızca sınırlı bir durum kodu kümesine ekler: çoğunlukla 200, 201, 204, 206 ve yönlendirmeler. 404 sayfanız, 500 sayfanız ve hız sınırına takılan her yanıt çıplak gider. Bu önemli, çünkü hata sayfası da sonuçta tarayıcıda HTML çalıştıran bir sayfadır.

Kimsenin fark etmemesinin sebebi hiçbir şeyin patlamaması. Config testi geçer, site açılır, elle kontrol ettiğiniz sayfalar zaten başlıkları hâlâ taşıyan sayfalardır. Boşluk, kimsenin doğrudan açmadığı path'lerdedir.

Nasıl görülür

Path'leri tek tek isteyin ve karşılaştırın. Sayfa da stil dosyası da aynı server bloğundan geldiğine göre aralarındaki fark doğrudan miras kuralıdır:

curl -sI https://ornek.com/ | grep -i 'strict-transport\|content-type-options\|referrer-policy\|content-security'
# strict-transport-security: max-age=31536000; includeSubDomains
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin
# content-security-policy: default-src 'self'

curl -sI https://ornek.com/assets/app.css | grep -ci 'strict-transport\|content-type-options\|referrer-policy\|content-security'
# 0

Aradığınız cevap o sıfırdır. Aynısını hata dönen bir path için de yapın, çünkü eksik always orada ortaya çıkar:

curl -sI https://ornek.com/olmayan-sayfa | grep -ci 'content-security-policy'

İşin diğer yarısı config'i okumak. Server seviyesinin altındaki her add_header, mirasın kesildiği bir noktadır, o yüzden hepsini listeleyin:

grep -rn 'add_header' /etc/nginx/ | grep -v '^\s*#'

Bu çıktıda bir location görüyorsanız ve orada bütün başlık setini yeniden tanımlamak gibi bir niyetiniz yoktuysa, o location bütün başlık setini yeniden tanımlıyor demektir.

Tarayıcı da aynı şeyi söyler, üstelik daha hızlı. Ağ panelinde bir sayfa isteğiyle bir asset isteğini yan yana açıp Response Headers bölümlerini karşılaştırmak, hangi başlığın nerede kaybolduğunu tek bakışta gösterir. Elle bakmanın tek riski, kontrol ettiğiniz path'in tesadüfen doğru bloğa düşen path olması. Bu yüzden elle bakmayı teşhis için kullanın, doğrulamayı script'e bırakın.

Çözüm

Üç yol var ve farklı durumlara uyuyorlar.

Birincisi, soruna yol açan şey için add_header kullanmayı bırakmak. Cache süresinin kendi direktifi var ve expires bu miras kuralına hiç girmiyor:

location ~* \.(css|js|woff2)$ {
  expires 1y;
  access_log off;
}

Bu satırlar Cache-Control: max-age=31536000 ve bir Expires başlığı gönderir, server seviyesindeki dört güvenlik başlığı da normal şekilde miras alınır. En ucuz çözüm bu ve tek istediğiniz cache ise yeterli. Değerin içinde immutable gerekiyorsa yeniden add_header kullanmak zorunda kalırsınız ve sorun geri döner.

İkincisi, kuralı kabul edip bilerek tekrar etmek. Başlıkları tek bir dosyaya koyun ve kendi başlığını tanımlayan her seviyede include edin:

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'" always;
server {
  include snippets/security-headers.conf;

  location / {
    try_files $uri $uri/ =404;
  }

  location ~* \.(css|js|woff2)$ {
    include snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }
}

Tek dosya, başlık başına tek değer, dilin zorunlu kıldığı yerde tekrar. Bedeli şu: yarın başkasının yazdığı yeni bir location bloğu include'u unutacak. Bir sonraki bölümdeki kontrolün sizin hafızanızda değil deploy script'inizde durmasının sebebi de bu.

Üçüncüsü, başlıkları ekleyen değil değiştiren başlık modülüne geçmek. nginx'i zaten üçüncü parti modüllerle derliyorsanız miras sorununu kökten çözer. Dört başlık için yapılacak büyük bir değişikliktir, bu yüzden ancak mükerrer başlık sorununu da yaşıyorsanız değer.

Seçimi yaparken tek bir ölçüt var: başlık setinin sahibi kim olacak. Cevap "server bloğu" ise alt seviyelerde add_header kullanmamayı kural haline getirin ve cache işini expires'a bırakın. Cevap "her seviye kendi setini taşısın" ise include'u zorunlu kılın ve kontrolü otomatik hale getirin. Yarısı burada yarısı orada duran bir düzen, altı ay sonra kimsenin hangi path'te hangi başlığın olduğunu bilmediği bir config üretir.

Hangisini seçerseniz seçin, madem elinizi attınız, her güvenlik başlığının sonuna always ekleyin.

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

Ana sayfayı değil, bir path kümesini kontrol edin. Aşağıdaki döngü farklı davranan path'leri dolaşır ve her birinde neyin eksik olduğunu yazar:

paths="/ /hakkinda/ /assets/app.css /assets/app.js /olmayan-sayfa"
required="strict-transport-security x-content-type-options referrer-policy content-security-policy"

for path in $paths; do
  headers=$(curl -sI "https://ornek.com$path" | tr 'A-Z' 'a-z')
  missing=""
  for h in $required; do
    echo "$headers" | grep -q "^$h:" || missing="$missing $h"
  done
  printf '%-22s %s\n' "$path" "${missing:-ok}"
done

Düzeltmeden önce iki asset path'i ve hata sayfası bir liste yazar. Düzeltmeden sonra her satır ok döner. Script'i saklayın, yeni bir location bloğu eklediğinizde listeye bir path ekleyin ve her deploy sonrası çalıştırın. Bir saniyeden kısa sürer ve bir sonraki kişinin ekleyeceği add_header satırını yakalayan tek şeydir.

Nelere dikkat etmeli

  • Diğer arıza biçimi mükerrer başlık. add_header ekleme yaptığı için iki seviyede tanımlı bir başlık yanıta iki kez girer ve içerik güvenlik politikasında tarayıcı iki politikanın kesişimini uygular. Sayfa, mükerrer başlık gibi değil politikada yazım hatası varmış gibi bozulur. Katı bir politika satır içi ilk boyamayla çalışırken zaten yeterince hassastır.
  • Location içindeki if bloğu da bir seviyedir, oraya konan tek bir add_header o isteklerde geri kalanını düşürür.
  • nginx kendi başlıklarını koyan bir uygulamaya proxy yapıyorsa tarayıcıya iki set birden ulaşır. Her başlığın sahibini tek bir yerde belirleyin, diğer tarafta o başlığı temizleyin.
  • Vhost dosyasının sahibi bir hosting paneliyse dosyayı yeniden üretir ve ilk sertifika yenilemesinde include satırlarınızı siler. Dosyanın başında "üretilmiştir" uyarısı varsa değişikliği dosyaya değil, panelin okuduğu yere yazın.
  • Öndeki CDN başlık ekleyebilir, silebilir ya da sırasını değiştirebilir. Bu yüzden doğrulamayı origin'e değil, dışarıdan görünen adrese karşı çalıştırın.
  • Aynı sınıftan bir hata server bloğunun başka yerinde de saklı. Yanlış yere konmuş bir root, try_files sonucunu değiştirir ve .env dosyanızı internete açabilir, üstelik yine config testinde hiçbir hata vermeden.

Buradaki genel ders, birleştirmeyi kesişim değil de değiştirme mantığıyla yapan konfigürasyon dilleriyle ilgili. nginx bu davranışı dokümantasyonda açıkça yazıyor, çalışma anında ise hiç ses çıkarmıyor. Bir kez yazıp bir daha bakmadığınız bir başlık için bu, olabilecek en kötü ikili. Bir direktif miras alınıyorsa hangi seviyenin sahibi olduğunu yazılı bir yere koyun ve sonucu herkesin açtığı tek path'te değil, kendi bloğu olan path'lerde doğrulayın. Bir path listesini dolaşan kontrol her deploy'da bir saniyeye mal olur ve görünmez bir kuralı görünür hale getirir.

Sorular ve cevaplar

nginx güvenlik başlıkları neden bazı path'lerde kayboluyor?
Çünkü add_header üst seviyeden yalnızca bulunulan seviyede hiç add_header yoksa miras alınır. Bir location bloğuna tek bir add_header girdiği anda server ya da http seviyesindeki bütün add_header satırları o location için geçersiz olur. Bu bir hata değil belgelenmiş davranış olduğu için config nginx -t testinden sorunsuz geçer.
expires direktifi de mirası bozar mı?
Hayır. Miras kararı bulunulan seviyede add_header olup olmamasına bakar, expires ise başka bir direktiftir. Cache süresini expires ile vermek, location'ın miras aldığı güvenlik başlıklarını korumasını sağlar. Tek derdiniz cache ise en ucuz çözüm budur.
add_header sonundaki always ne işe yarar?
always olmadan nginx başlığı yalnızca belli durum kodlarına ekler: çoğunlukla 200, 201, 204, 206 ve yönlendirmeler. 404, 500 gibi hata yanıtları başlıksız gider. always eklendiğinde başlık her yanıta uygulanır ve güvenlik başlıkları için istediğiniz şey tam olarak budur.
Aynı başlık iki seviyede tanımlıysa ne olur?
add_header ekleme yapar, yani yanıtta başlık iki kez gider. İçerik güvenlik politikasında tarayıcı iki politikanın kesişimini uygular, bu da ikisinden de katıdır ve sayfayı sık sık bozar. Başlık include'unu birden fazla yere koyduğunuzda mükerrer başlık kontrolü yapın.
Güvenlik başlıklarını her path için nasıl test ederim?
Temsil gücü olan bir path listesini curl ile isteyin: kök adres, bir alt sayfa, bir stil dosyası, bir script ve olmayan bir sayfa. Her yanıtın başlıklarını zorunlu listeyle karşılaştırın. Bu kontrolü her config değişikliğinden sonra çalıştırın, çünkü yeni eklenen tek bir location bloğu her şeyi geri almaya yeter.