teknikbakis

Yapılandırmada altı güvenlik başlığı, panelin yanıtında sıfır: nginx add_header kalıtımı

Sunucu bloğunda CSP, HSTS, X-Frame-Options ve nosniff tanımlıydı; nginx -T hepsini gösteriyordu. Ama kendi Cache-Control başlığını ekleyen her location, üst düzeydeki listenin tamamını sessizce bırakıyordu. Kök adres temizdi, panel çıplaktı.

DOSYA TB-2026-008
SİSTEM nginx · yönetim paneli
KATMAN HTTP yanıt başlıkları
SÜRE Aylarca görünmedi · aynı gün kapandı
DURUM ÇÖZÜLDÜ
KANIT 3 ek

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ı.

EK-1 Yol yol yanıt ölçümü · 27 Eylül
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:

EK-2 Statik dizin, oturumsuz istek · 3 Ekim
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.

ÇIKARIM

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ı.

EK-3 Giriş sayfası, düzeltme sonrası · 3 Ekim
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_header birikmez, yer değiştirir. Location'a eklenen tek satır, üst düzeydeki listeyi o location için siler. proxy_set_header da böyle davranır.
  • Ortak başlıkları tek parçada tut. add_header yazan her location'a include et ve kuralı parçanın içine, ilk satırlara yaz.
  • always ayrı 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.