Semptom
Bir yönetim panelini uzun süredir nginx arkasında yayınlıyorum. Sunucu bloğunun başında, her yeni kurulumda taşıdığım güvenlik başlıkları duruyordu: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy ve artık kullanılmayan X-XSS-Protection. Altı satır. Kâğıt üstünde panel, başka bir sitenin çerçevesine gömülmeye, MIME karıştırmaya ve düz HTTP'ye düşürülmeye karşı korunuyordu.
27 Eylül 2026'da panelin adres düzenini değiştirdim: kök adrese bir tanıtım sayfası geldi, panel kendi yoluna taşındı, önüne bir giriş sayfası kondu. Değişiklikten sonra yapılandırmaya değil, yanıtlara baktım — her yol için ayrı ayrı.
curl -s -D - -o /dev/null https://ornek-alan.com/ \
| grep -i -E "content-security|strict-transport|x-frame"
# üç satır: CSP, HSTS, X-Frame-Options
curl -s -D - -o /dev/null https://ornek-alan.com/panel \
| grep -i -E "content-security|strict-transport|x-frame"
# (boş — tek satır yok)
Kök adres eksiksizdi. Panelde, kurumsal sayfada ve panelin kütüphane dosyalarını sunan statik dizinde bu üç başlığın hiçbiri yoktu. Alan adı ve yollar örnekle değiştirildi.
Aynı nedenle API'nin yanıtlarında da yoktular. Ne kadar süredir böyle olduğunu gün olarak söyleyemem; location blokları aylardır aynıydı. Kimsenin fark etmemesinin nedeni basit: güvenlik başlığının yokluğu hiçbir sayfayı kırmaz. Sayfa açılır, form çalışır, grafikler çizilir. Eksik olan şey yalnızca bir saldırının önündeki engeldir ve o engelin yokluğu, saldırı gelene kadar görünmez.
Yanlış yollar
Bu tuzakta insanı rahatlatan üç okuma var. Üçü de yanlış yere bakıyor.
"nginx -T çıktısında satırlar duruyor, demek gidiyor." Aylarca
güvencem buydu. nginx -T yapılandırmanın tamamını döker ve
add_header satırları sunucu bloğunda, yazdığım gibi duruyordu. Ama bu çıktı
yalnızca ne yazdığımı gösterir, hangi isteğe neyin uygulanacağını değil.
nginx -t de söz dizimini denetler; gölgede kalmış bir add_header
için uyarı vermez. Yapılandırma bir niyet beyanıdır, yanıt ise sonuç.
"Tarayıcıda ana sayfaya baktım, başlıklar orada." Kök adres, kendi location bloğunda başlıkları tek tek yazan tanıtım sayfasıydı. Geliştirici araçlarında ya da dışarıdan bir başlık tarayıcısında yalnız kök adresi ölçseydim tablo kusursuz görünecekti. Başlıklar site başına değil location başına belirlenir; bir adresin sonucu, başka bir location hakkında hiçbir şey söylemez.
"always eksik olmalı." İlk akla gelen teknik açıklama bu ve
yanlış. always kalıtımla ilgili değildir, başlığın hangi durum kodlarında
gönderileceğini belirler. Onsuz add_header yalnız 200, 201, 204, 206, 301, 302,
303, 304, 307 ve 308 yanıtlarına eklenir; always ile 401, 404 ve 500 gibi
yanıtlara da. Sunucu düzeyindeki satırlarda always zaten vardı — panelin 200
yanıtında yine de yoktular. Farkı bugünkü yapılandırmada görmek mümkün:
HTTP/2 401
strict-transport-security: max-age=31536000; includeSubDomains
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: DENY
Bu location'da iki tür satır var: always parametreli güvenlik parçası ve
always parametresiz bir Cache-Control. Oturumsuz istek 401 döndü;
parametreli beş başlık geldi, Cache-Control gelmedi. always durum
koduna bakar, kalıtıma değil. Tarih, içerik türü ve sunucu satırları çıkarıldı.
Doğru test
Ayırt edici test basit: her location için bir adres seç ve yanıtı ölç. Yapılandırmadaki location listesi, test listesinin kendisidir.
for yol in / /panel /kurumsal /vendor/; do
n=$(curl -s -D - -o /dev/null "https://ornek-alan.com$yol" \
| grep -c -i -E "^(content-security-policy|strict-transport-security|x-frame-options):")
printf '%-12s %s/3\n' "$yol" "$n"
done
Hangi location'ların var olduğunu yapılandırmanın kendisinden çıkarıyorum. Burada aranan
desen tek: kendi add_header satırı olan ama ortak parçayı
include etmeyen her location. O location'da sunucu düzeyindeki liste geçersizdir.
nginx -T 2>/dev/null | grep -n -E '^\s*(server_name|location|if|add_header|include)\b'
Sıra önemli: önce yanıt, sonra yapılandırma. Yapılandırma okuması nerede arayacağımı söyler; neyin gerçekten gittiğini yalnızca yanıt söyler.
Kök neden
nginx belgesindeki kural tek cümle: add_header yönergeleri, yalnızca mevcut
düzeyde hiç add_header tanımlı değilse bir önceki düzeyden miras alınır.
Düzeyler http → server → location → location içindeki
if. Liste birleşmez. Location'da tek bir add_header varsa, üst
düzeyin listesi o location için tamamen yok sayılır.
Sadeleştirilmiş hâliyle panelin eski yapılandırması:
server {
server_name ornek-alan.com;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'" always;
location = / { # tanıtım sayfası: başlıkları kendisi yazıyor
add_header Cache-Control "public, max-age=300";
add_header X-Frame-Options "DENY" always;
# ... diğerleri de tek tek
}
location / { # panel
add_header Cache-Control "no-cache, no-store, must-revalidate";
# bu satır yüzünden yukarıdaki dört başlık burada YOK
}
location /vendor/ { # panelin kütüphane dosyaları
add_header Cache-Control "private, max-age=31536000, immutable";
# burada da YOK
}
}
Her location'a eklenen Cache-Control masum bir önbellek ayarıydı. nginx'in
gözünde ise "bu location'ın başlık listesi budur" demekti. Kök adresin temiz görünmesi de
şanstı: tanıtım sayfası başlıkları zaten kendi bloğunda tekrar ediyordu.
add_header birikmez, yer değiştirir. Bir location'a tek bir başlık eklemek,
o location için "üst düzeydeki başlıkları unut, yalnız bunu gönder" demektir. Aynı kural
proxy_set_header için de geçerlidir. Location içindeki bir if
bloğu da ayrı bir düzeydir: orada yazılan add_header, o koşula giren isteklerde
location'ın başlıklarını da düşürür.
Çözüm
Başlıkları tek yerde tutup her yerde çağırmak. Ortak parçayı bir snippet'e aldım; CSP'yi sayfa türüne göre ayrı iki parçaya böldüm, çünkü oturumlu panelin ihtiyacı ile herkese açık sayfanınki farklı.
# /etc/nginx/snippets/guvenlik-basliklari.conf
# add_header kalıtımı hep-ya-da-hiç: add_header kullanan HER location bunu include etmeli.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
Uyarı dosyanın ikinci satırında duruyor: parçayı açan bir sonraki kişi, ya da altı ay sonraki ben, kuralı orada görsün. Location'lar şöyle oldu:
server {
include snippets/guvenlik-basliklari.conf;
include snippets/csp-panel.conf;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
include snippets/guvenlik-basliklari.conf;
include snippets/csp-panel.conf;
}
location /vendor/ {
add_header Cache-Control "private, max-age=31536000, immutable";
include snippets/guvenlik-basliklari.conf; # CSP yok: belge değil, kütüphane dosyası
}
}
Sunucu düzeyindeki include yerinde kalıyor; kendi add_header satırı
olmayan location'lar oradan miras alır. Çift başlık oluşmaz, çünkü location'da
add_header varken üst düzey hiç uygulanmaz. Ardından nginx -t,
yeniden yükleme ve Doğru test'teki döngü. X-XSS-Protection bu sırada listeden çıktı; güncel
tarayıcılar o başlığı artık kullanmıyor.
Asıl risk çözümün ikinci yarısındaydı: panel CSP'yi hayatında ilk kez uygulayacaktı. Hiç CSP
görmemiş bir sayfaya birden zorlayıcı politika vermek; satır içi olay işleyicilerini
(onclick=), harita karolarını ve üçüncü taraf betikleri sessizce kırabilir. Bu
kırılma sunucu log'una düşmez, yalnızca kullanıcının tarayıcı konsolunda görünür. Genel yol,
aynı politikayı önce rapor kipinde göndermektir:
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' https://karo.ornek-alan.com" always;
Tarayıcı bu başlıkla hiçbir şeyi engellemez; ihlali konsola yazar, raporlama ucu tanımlıysa
oraya da gönderir. Gerçek kullanımla birkaç gün toplayıp sonra
Content-Security-Policy'ye çevirmek, çok kullanıcılı ve çok sayfalı bir
uygulamada doğru sıradır.
Bu panelde rapor kipini atladım, çünkü sayfaların hepsini sayabiliyordum. Politikayı zorlamadan önce, oturum açık bir başsız (headless) tarayıcıyla panelin 11 sayfasının tamamını gezdim: CSP ihlali sıfır; canlı veri akışı (WebSocket) ve grafikler çalışıyordu. Sayfa listesi küçük ve kapalıysa böyle bir tarama rapor kipinin yerini tutar; değilse tutmaz. CSP'nin içeriğini sıkılaştırmak ayrı bir iş. Bu kaydın konusu, yazılmış bir politikanın yanıta hiç ulaşmamasıydı.
curl -s -D - -o /dev/null https://ornek-alan.com/giris \
| grep -i -E "content-security|strict-transport|x-frame"
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'self'; ... (kısaltıldı)
x-frame-options: DENY
3 Ekim'de dört yolu yeniden ölçtüm. Kök adres, giriş sayfası ve kurumsal sayfanın oturumsuz istekte girişe yönlendiren yanıtı üç başlığı da taşıyor. Statik dizin ortak parçayı alıyor, CSP almıyor (EK-2) — bilerek.
Statik dizindeki tercih şu: CSP, tarayıcının belge olarak işlediği yanıtlarda (ve worker
betiklerinde) anlam taşır. Bir sayfanın <script> ile yüklediği kütüphane
dosyasının yanıtına eklenen CSP o dosyayı korumaz; o dosyayı kullanan sayfanın CSP'si korur.
O dizinde iş gören başlık, dosyanın türü dışında yorumlanmasını engelleyen
nosniff ve o yanıtta var.
Kalıcı ders
- Başlık yapılandırmada değil, yanıtta yaşar. Doğrulamayı her location için bir adresle, yanıttan yap. Tek adresin temiz çıkması, sitenin temiz olduğunu göstermez.
-
add_headerbirikmez, yer değiştirir. Location'a eklenen tek satır, üst düzeydeki listeyi o location için siler.proxy_set_headerda böyle davranır. -
Ortak başlıkları tek parçada tut.
add_headeryazan her location'aincludeet ve kuralı parçanın içine, ilk satırlara yaz. -
alwaysayrı bir sorudur. Kalıtımı değil, durum kodunu belirler. 401 ve 404 sayfalarında da başlık istiyorsan gerekir; kalıtım sorununu çözmez. - Hiç CSP görmemiş sayfaya önce rapor kipi. Sayfa listesi küçükse oturumlu tam bir tarama da olur; ikisi de yoksa zorlayıcı CSP'yi canlıda deneme.
Güvenlik başlığının yokluğu hiçbir şeyi bozmaz; bu yüzden hiçbir alarm onu bulmaz. Bulmanın tek yolu, her kapıyı tek tek yoklamak.