Mert Özen Her Satırda Daha İleri
DevOps SSL/TLS Certificate'ları ve HTTPS — #06

HTTPS'i Zorunlu Kılmak: HTTP→HTTPS Redirect, HSTS'i Kademeli Açmak, Preload ve Mixed Content

Mert Özen 14 Kas 2026 16 dk 5 görüntülenme
HTTPS'i Zorunlu Kılmak: HTTP→HTTPS Redirect, HSTS'i Kademeli Açmak, Preload ve Mixed Content

HTTPS'i zorunlu kılmak için redirect'i TLS'in bittiği katmanda yaparsın, API'lerde redirect yerine HTTP'yi 400 ile reddedersin ve HSTS'i 5 dakikadan bir yıla kademeli açarsın; includeSubDomains'ten önce alt domain envanteri şart. Proxy arkasında UseHttpsRedirection döngüsünü ve secure'suz cookie'yi, 301'in POST'u GET'e çevirmesini, HSTS'in HTTP-only alt domain'i kilitlemesini ve Angular build'indeki mutlak http:// URL'in mixed content olarak bloklanmasını gerçek koşularla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler Nginx 1.30.6, .NET 10 (SDK 10.0.402, ASP.NET Core runtime 10.0.15), Angular 22.2, curl 8.14 ve Playwright 1.65 (Chromium) ile Docker 29 üzerinde denenmiştir; certificate'lar laboratuvar için üretilmiş bir test CA'sından (sertifika veren güvenilir kuruluş) gelir.

Önce Tahmin Et

Bookstore'un bütün domain'leri artık geçerli certificate'larla çalışıyor ve ekip HTTPS'i (HTTP Secure) zorunlu kılmak için iki adım atıyor. Birincisi: Nginx'te 80 portuna gelen her isteğe return 301 https://$host$request_uri;. İkincisi: bookstore.example'ın HTTPS yanıtlarına Strict-Transport-Security: max-age=31536000; includeSubDomains header'ı.

Üç soru. Eski bir mobil sürüm siparişi hâlâ POST http://www.bookstore.example/api/orders'a gönderiyor; sipariş API'ye ulaşır mı? Muhasebenin yıllardır HTTP üzerinden açtığı iç rapor sitesi reports.bookstore.example'a ertesi gün ne olur? Hata fark edilip header kaldırılırsa, rapor sitesi ne zaman düzelir? Hepsini gerçek koşularla göreceğiz.

Redirect Nerede Yapılmalı?

HTTP'den HTTPS'e redirect'i yapabileceğin üç katman var: CDN (statik dosyaları kullanıcıya yakın sunuculardan dağıtan ağ), reverse proxy ve uygulamanın kendisi. Kural basit: redirect'i TLS'in sonlandığı katmanda yap. İsteğin şifreli gelip gelmediğini en doğru bilen o katman. Bookstore'da TLS Nginx'te bittiği için redirect de orada:

server {
    listen 80;
    server_name www.bookstore.example bookstore.example;

    location /api/ {
        return 308 https://$host$request_uri;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 80;
    server_name api.bookstore.example;
    return 400 "HTTPS required\n";
}
$ curl -sI "http://www.bookstore.example/books?page=2" | grep -E "HTTP|Location"
HTTP/1.1 301 Moved Permanently
Location: https://www.bookstore.example/books?page=2

$host ve $request_uri path'i ve query string'i koruyor; kullanıcı yer imindeki adresle aynı sayfaya düşüyor. /api/ için 308'i ve API host'u için redirect yerine 400'ü bir sonraki bölümde açacağım. TLS bir CDN'de bitiyorsa redirect'i de CDN'in kendi ayarıyla yap; CDN ile origin arasındaki bağlantı HTTP ise ve origin de redirect ediyorsa, bitmeyen bir döngü elde edersin. CDN tarafının ayrıntıları, DNS kayıtlarıyla birlikte, Domain, DNS ve CDN serisinin konusu. Nginx'te HTTPS server block'unun temeli ise Web Server'lar ve Hosting serisinin "Nginx'te HTTPS, Security Header'ları, Rate Limiting ve Load Balancing" yazısında.

ASP.NET Core şablonları app.UseHttpsRedirection() ile geliyor ve bu satır proxy arkasında da kalıyor. Sorun şu: Nginx TLS'i bitirip isteği Kestrel'e düz HTTP ile iletiyor. Kestrel'in gözünde her istek HTTP. Bookstore.Api'yi bu yazı için şöyle kurdum:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddHttpsRedirection(options => options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // yalnızca Nginx container'ının ağına güven
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.30.0.0/16"));
});

WebApplication app = builder.Build();

if (app.Configuration.GetValue<bool>("Proxy:UseForwardedHeaders"))
{
    app.UseForwardedHeaders();
}

// API: asla redirect etme, düz HTTP'yi reddet
app.Use(async (context, next) =>
{
    if (!context.Request.IsHttps && context.Request.Path.StartsWithSegments("/api"))
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsync("HTTPS required\n");
        return;
    }

    await next(context);
});

app.UseHttpsRedirection();

app.MapGet("/api/health", (HttpContext context) => $"ok scheme={context.Request.Scheme}\n");
app.MapPost("/api/orders", (HttpContext context) => $"{context.Request.Method} /api/orders accepted\n");
app.MapGet("/account/login", async (HttpContext context) =>
{
    ClaimsPrincipal user = new(new ClaimsIdentity([new Claim(ClaimTypes.Name, "ada")], CookieAuthenticationDefaults.AuthenticationScheme));
    await context.SignInAsync(user);
    return $"signed in, scheme={context.Request.Scheme}\n";
});

app.Run();

Proxy:UseForwardedHeaders anahtarı yalnızca farkı göstermek için var; gerçek uygulamada UseForwardedHeaders koşulsuz ve pipeline'ın en başında durur. Üç koşu yaptım. Birincisinde forwarded header'lar kapalı ve HTTPS portu tanımsız:

$ curl -sS -D - https://www.bookstore.example/account/login | grep -iE "^HTTP|set-cookie|signed"
HTTP/2 200
set-cookie: .AspNetCore.Cookies=CfDJ8...; path=/; samesite=lax; httponly
signed in, scheme=http
$ curl -sS https://www.bookstore.example/api/health
HTTPS required
$ docker logs tlsseries-api 2>&1 | grep -A1 HttpsRedirectionMiddleware
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
      Failed to determine the https port for redirect.

Tarayıcı HTTPS ile gelmiş ama uygulama scheme=http görüyor. Middleware hangi porta redirect edeceğini bilmediği için bir uyarı yazıp isteği geçiriyor. Asıl kayıp cookie'de: authentication cookie'si secure flag'i olmadan gidiyor, çünkü cookie authentication'ın varsayılan SecurePolicy'si SameAsRequest, istek de uygulamanın gözünde HTTP. Bu cookie, kullanıcı bir gün herhangi bir http:// adrese gittiğinde düz metin olarak gönderilir. Bizim HTTP reddi yapan middleware'imiz de HTTPS ile gelen meşru API isteğini reddediyor.

İkinci koşuda biri "redirect çalışmıyor" deyip ASPNETCORE_HTTPS_PORT=443 ekliyor:

$ curl -sS -L -o /dev/null -w "%{num_redirects} redirects, last %{http_code} %{url_effective}\n" \
    https://www.bookstore.example/account/login
curl: (47) Maximum (50) redirects followed
50 redirects, last 308 https://www.bookstore.example/account/login

Klasik döngü: uygulama HTTPS isteğini HTTP sanıp HTTPS'e yönlendiriyor, tarayıcı aynı HTTPS adrese geri geliyor, Nginx yine HTTP ile iletiyor. Tarayıcıda bu "too many redirects" hatası olarak görünür. Üçüncü koşuda forwarded header'ları açtım:

$ curl -sS -D - -o /dev/null https://www.bookstore.example/account/login | grep -i set-cookie
set-cookie: .AspNetCore.Cookies=CfDJ8...; path=/; secure; samesite=lax; httponly
$ curl -sS https://www.bookstore.example/api/health
ok scheme=https
$ curl -sS http://api:8080/api/health
HTTPS required
$ curl -sSI http://api:8080/account/login | grep -iE "^HTTP|location"
HTTP/1.1 308 Permanent Redirect
Location: https://api/account/login

Uygulama artık şemayı X-Forwarded-Proto'dan okuyor: cookie secure, API çalışıyor, proxy'yi atlayıp doğrudan Kestrel'e gelen düz HTTP isteği ise reddediliyor ya da redirect ediliyor. KnownIPNetworks burada kritik. Bu header'a yalnızca proxy'nin IP adresinden geldiğinde güvenilmeli; aksi halde herkes X-Forwarded-Proto: https yazıp uygulamayı kandırabilir. Mekanizmanın kendisi Web Server'lar ve Hosting serisinin "Reverse Proxy Arkasında ASP.NET Core: Forwarded Headers, KnownIPNetworks ve Doğru Client IP" yazısında. Redirect'i Nginx yapıyorsa uygulamadaki UseHttpsRedirection gereksiz; tutacaksan HTTPS portunu açıkça ver ve forwarded header'ları ondan önce çalıştır. Cookie'ler için de şansa bırakma: options.Cookie.SecurePolicy = CookieSecurePolicy.Always proxy ayarı bozulsa bile secure flag'ini garanti eder.

API'lerde Redirect Değil, Ret

Önce Tahmin Et'in ilk sorusuna dönelim. Nginx config'inde sayfalar için 301, /api/ için 308 var. Aynı POST'u ikisine de gönderdim; -L curl'ün redirect'i takip etmesini sağlıyor:

$ curl -sL --cacert root.crt -d '{"bookId":42}' -H "Content-Type: application/json" -o /dev/null \
    -w "%{num_redirects} redirect -> %{method} %{url_effective} %{http_code}\n" http://www.bookstore.example/orders
1 redirect -> GET https://www.bookstore.example/orders 200
$ curl -sL --cacert root.crt -d '{"bookId":42}' -H "Content-Type: application/json" \
    http://www.bookstore.example/api/orders
POST /api/orders accepted

301'i takip eden curl, POST'u GET'e çevirdi ve body'yi düşürdü; tarayıcılar da tarihsel olarak aynısını yapar. Sonuç 200, ama sipariş yok: istemci başarılı bir yanıt aldı, sunucu ise yalnızca ana sayfayı döndü. 308 method'u ve body'yi koruyor, sipariş ulaştı. Yani ilk sorunun cevabı, redirect'in koduna ve istemcinin onu nasıl takip ettiğine bağlı. 301 ile sipariş sessizce kayboluyor.

308 de API için yeterli değil. İsteğin içinde bir token olduğunda neler olduğuna bak:

$ curl -sv -L --cacert root.crt -H "Authorization: Bearer eyJhbGciOi.demo" -d '{"bookId":42}' \
    http://www.bookstore.example/api/orders 2>&1 | grep -E "^(> (POST|Host|Authorization)|< HTTP|\* (Connected|Issue|SSL connection))"
* Connected to www.bookstore.example (127.0.0.1) port 80
> POST /api/orders HTTP/1.1
> Host: www.bookstore.example
> Authorization: Bearer eyJhbGciOi.demo
< HTTP/1.1 308 Permanent Redirect
* Issue another request to this URL: 'https://www.bookstore.example/api/orders'
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / RSASSA-PSS
* Connected to www.bookstore.example (127.0.0.1) port 443
> POST /api/orders HTTP/2
> Host: www.bookstore.example
< HTTP/2 200

Token, redirect gelmeden önce 80 portundan düz metin olarak gitti. Ağdaki herhangi biri onu okuyabilirdi ve redirect bu konuda hiçbir şey yapamaz, çünkü iş olmuştur. Bir ayrıntı daha: curl, şema ve port değişen redirect'te Authorization header'ını güvenlik gereği yeniden göndermedi. Gerçek bir API'de ikinci istek 401 alırdı ve geliştirici "token çalışmıyor" diye saatlerini harcardı. Microsoft'un önerisi de bu yönde: API'ler ya HTTP dinlememeli ya da HTTP isteğini 400 ile reddetmeli; redirect, API istemcilerinin anlamayabileceği veya uymayabileceği bir şey. Bookstore'da api.bookstore.example 80'de redirect etmiyor, Nginx 400 dönüyor. Kestrel doğrudan internete açıksa yukarıdaki middleware aynı işi yapıyor; doğrudan Kestrel'e giden HTTP isteğine verdiği 400'ü az önce gördük:

$ curl -sv -H "Authorization: Bearer eyJhbGciOi.demo" -d '{"bookId":42}' http://api.bookstore.example/api/orders 2>&1 \
    | grep -E "^(> (POST|Authorization)|< HTTP)|HTTPS required"
> POST /api/orders HTTP/1.1
> Authorization: Bearer eyJhbGciOi.demo
< HTTP/1.1 400 Bad Request
HTTPS required

Token yine düz metin gitti. Ret onu geri almıyor, ama hatayı ortaya çıkarıyor: yanlış URL ile yapılandırılmış istemci ilk testte kırılır ve düzeltilir. Redirect ise aynı hatayı aylarca çalışıyormuş gibi gösterir. Sızan token'ı da rotate et. Tarayıcı tarafında bir neden daha var: CORS preflight isteği redirect'i takip etmiyor ve hata veriyor.

HSTS: Tarayıcıya "Bir Daha HTTP Deneme" Demek

Redirect, ilk isteğin HTTP ile gitmesini engelleyemez. Kullanıcı adres çubuğuna bookstore.example yazar, tarayıcı HTTP'yi dener ve saldırgan o ilk yanıtı değiştirip kullanıcıyı HTTPS'e hiç geçirmeyebilir (SSL stripping). HSTS (tarayıcıyı siteye yalnız HTTPS ile bağlanmaya zorlayan header) bu boşluğu kapatıyor; RFC 6797 ile tanımlı bu mekanizmada HTTPS yanıtındaki header, tarayıcıya "bu host'a max-age saniye boyunca yalnızca HTTPS ile gel" der. curl'ün HSTS cache'i tarayıcının davranışını konsolda göstermek için iyi bir araç:

$ curl -sS -o /dev/null -D - --cacert root.crt --hsts hsts.txt https://www.bookstore.example/ | grep -i strict
strict-transport-security: max-age=300
$ grep -v "^#" hsts.txt
www.bookstore.example "20261113 22:44:49"
$ curl -sv -o /dev/null --cacert root.crt --hsts hsts.txt http://www.bookstore.example/books 2>&1 | grep -E "HSTS|Connected|^< HTTP"
* Switched from HTTP to HTTPS due to HSTS => https://www.bookstore.example/books
* Connected to www.bookstore.example (127.0.0.1) port 443
< HTTP/2 200

İkinci istekte 80 portuna hiç bağlanılmadı. İstemci http://'yi kendi içinde https://'ye çevirdi; tarayıcılar da aynısını yapar. HSTS'in üç kuralı var. Header yalnızca HTTPS yanıtında geçerli; HTTP yanıtına koyduğun header yok sayılır, çünkü araya giren biri onu ekleyebilir ya da silebilir. HSTS'li bir host'ta certificate hatası olursa tarayıcı "yine de devam et" seçeneğini göstermez. Üçüncüsü ve en sık unutulanı: kayıt host bazında tutulur, port bazında değil. Bunu bir sonraki bölümde göreceğiz.

HSTS'i Kademeli Açmak

HSTS'in riski geri alınmasının zor olması. max-age=31536000 gönderdiğin anda, o yanıtı alan her tarayıcı bir yıl boyunca o host'a HTTP ile gitmeyi reddeder. Bir hata fark edip header'ı kaldırsan da etkisi geçmez. Bu yüzden yayın planı kademeli olmalı.

Aşamalar

Microsoft, ilk HTTPS geçişinde max-age'i saatlerle, en fazla bir günle başlatmayı öneriyor. hstspreload.org'un önerdiği basamaklar ise 5 dakika (300), 1 hafta (604800), 1 ay (2592000) ve en son 1 yıl. Her basamakta, bir sonrakine geçmeden önce o basamağın max-age'i kadar bekle ve kırılan sayfa, hata oranı, destek talebi var mı bak. Bir basamağı geri almak, en fazla o basamağın süresi kadar etki bırakır. Bookstore'da plan şöyleydi: 300 saniye ile www, sonra apex için bir hafta, alt domain envanteri tamamlandıktan sonra includeSubDomains, en son bir yıl. Header üretiminin ASP.NET Core tarafı (AddHsts, MaxAge, ExcludedHosts) ASP.NET Core Güvenliği serisinin security header'larını ürettiği dördüncü yazısında; burada yalnızca yayın planı konumuz.

includeSubDomains'ten Önce Envanter

Önce Tahmin Et'in ikinci sorusu. Apex'e includeSubDomains ekledim ve önce apex'i ziyaret edip sonra iç rapor sitesine gittim:

$ curl -sS -o /dev/null -D - --cacert root.crt --hsts hsts.txt https://bookstore.example/ | grep -i strict
strict-transport-security: max-age=604800; includeSubDomains
$ grep -v "^#" hsts.txt
www.bookstore.example "20261113 22:44:49"
.bookstore.example "20261120 22:39:56"
$ curl -sS -v -o /dev/null --cacert root.crt --hsts hsts.txt http://reports.bookstore.example/ 2>&1 | grep -E "HSTS|subject|curl:"
* Switched from HTTP to HTTPS due to HSTS => https://reports.bookstore.example/
*  subject: CN=www.bookstore.example
*  subjectAltName does not match hostname reports.bookstore.example
* SSL: no alternative certificate subject name matches target hostname 'reports.bookstore.example'
curl: (60) SSL: no alternative certificate subject name matches target hostname 'reports.bookstore.example'

Kayıt başındaki nokta (.bookstore.example) bütün alt domain'leri kapsıyor. Rapor sitesine artık HTTP ile gidilemiyor; HTTPS'te ise ya hiç certificate yok ya da yanlış isimli bir certificate var. HSTS'li host'ta certificate hatası atlanamadığı için kullanıcının yapabileceği bir şey yok. Önce Tahmin Et'in cevabı: muhasebe, apex'i ziyaret ettiği andan itibaren bir hafta boyunca rapor sitesini açamaz. Kaldırmak için max-age=0 gönderdim:

$ curl -sS -o /dev/null --cacert root.crt --hsts hsts.txt https://bookstore.example/
$ grep -v "^#" hsts.txt
www.bookstore.example "20261113 22:44:49"
$ curl -sS --hsts hsts.txt http://reports.bookstore.example/
reports (internal, HTTP only)

Kayıt silindi, ama yalnızca apex'e tekrar HTTPS ile gelen istemci için. Tarayıcısı kayıtlı her kullanıcının apex'i bir kez daha ziyaret etmesi gerekiyor; ziyaret etmeyenlerde kayıt max-age dolana kadar kalıyor. Bu yüzden includeSubDomains'ten önce DNS zone'undaki bütün isimleri çıkar: iç araçlar, eski kampanya siteleri, VPN içinden açılan paneller, e-posta servislerinin tracking alt domain'leri. Her birinin HTTPS'i olmalı ya da başka bir domain'e taşınmalı. Certificate Transparency log'ları bu envanter için iyi bir başlangıç noktası: ikinci yazıda crt.sh ile gördüğümüz gibi, bir zamanlar certificate alınmış her alt domain orada görünür. HTTP-only bir alt domain ise orada hiç görünmez, onu DNS zone'undan bulman gerekir.

Preload: Geri Dönüşü Zor Bir Karar

HSTS ilk ziyareti korumaz; tarayıcının header'ı görmesi için en az bir HTTPS yanıtı alması gerekir. Preload listesi bu boşluğu kapatıyor: Chromium'un yönettiği, tarayıcıların içine gömülü bir liste. Bu listedeki domain'lere ilk istek bile HTTPS ile gider. hstspreload.org'un kabul şartları şunlar: geçerli bir certificate, aynı host'ta HTTP'den HTTPS'e redirect, iç olanlar dahil bütün alt domain'lerin HTTPS sunması ve apex'teki HSTS header'ında en az max-age=31536000, includeSubDomains ve preload. Geri dönüş zor: listeden çıkmak bir Chrome güncellemesiyle kullanıcılara ulaşır ve bu aylar sürer; diğer tarayıcılar için bir garanti de yok. Preload'u, kademeli yayının son basamağı bir ayı sorunsuz geçtikten ve bütün alt domain'lerin HTTPS'te olduğundan emin olduktan sonra düşün. Bir banka veya bir yönetim paneli için mantıklı. Alt domain'leri farklı ekiplerin yönettiği ve kimin ne açtığını bilmediğin bir kurumda ise riskli.

Mixed Content ve Angular'daki Mutlak URL'ler

Sayfa HTTPS ile gelip içindeki bir kaynağı HTTP ile istiyorsa buna mixed content denir. Bookstore'un Angular uygulamasında API adresi environment dosyasında mutlak bir URL olarak yazılmış ve kapak görseli template'te sabit:

export const environment = {
  apiUrl: 'http://www.bookstore.example',
};
@Component({
  selector: 'app-root',
  template: `
    <h1>Bookstore</h1>
    <img src="http://www.bookstore.example/cover.png" width="40" height="40" alt="cover">
    <p id="status">{{ status() }}</p>
  `,
})
export class App {
  protected readonly status = signal('loading');

  constructor() {
    inject(HttpClient)
      .get(`${environment.apiUrl}/api/health`, { responseType: 'text' })
      .subscribe({
        next: body => this.status.set(body.trim()),
        error: () => this.status.set('api call failed'),
      });
  }
}

Build sırasında environment değeri bundle'a gömülüyor; çalışma zamanında değiştirebileceğin bir ayar kalmıyor:

$ npx ng build
$ grep -o 'http://www.bookstore.example[^"`]*' dist/bookstore-web/browser/main-*.js | sort | uniq -c
   1 http://www.bookstore.example
   1 http://www.bookstore.example/cover.png

Sayfayı Playwright ile Chromium'da açıp console'u, başarısız istekleri ve yanıtları topladım:

import { chromium } from 'playwright';

const browser = await chromium.launch({
  args: ['--host-resolver-rules=MAP *.bookstore.example 127.0.0.1'],
});
// yalnızca lab: test root'u tarayıcının trust store'unda değil
const page = await browser.newPage({ ignoreHTTPSErrors: true });

page.on('console', m => console.log(`[console.${m.type()}] ${m.text()}`));
page.on('requestfailed', r => console.log(`[failed] ${r.url()} ${r.failure()?.errorText}`));
page.on('response', r => {
  if (r.url().includes('/api/') || r.url().endsWith('.png')) {
    console.log(`[response] ${r.status()} ${r.url()}`);
  }
});

await page.goto('https://www.bookstore.example/');
await page.waitForFunction(() => document.querySelector('#status')?.textContent !== 'loading');
console.log(`[status] ${await page.textContent('#status')}`);
await browser.close();
$ node mixed.mjs
[console.error] Mixed Content: The page at 'https://www.bookstore.example/' was loaded over HTTPS, but requested an insecure resource 'http://www.bookstore.example/api/health'. This request has been blocked; the content must be served over HTTPS.
[failed] http://www.bookstore.example/api/health mixed-content
[console.warning] Mixed Content: The page at 'https://www.bookstore.example/' was loaded over HTTPS, but requested an insecure element 'http://www.bookstore.example/cover.png'. This request was automatically upgraded to HTTPS, For more information see https://blog.chromium.org/2019/10/no-more-mixed-messages-about-https.html
[response] 200 https://www.bookstore.example/cover.png
[status] api call failed

İki farklı muamele var. API çağrısı engellendi: script, fetch ve XHR (XMLHttpRequest) gibi sayfanın davranışını değiştirebilen kaynaklar mixed content olarak bloklanır. Görsel ise Chromium tarafından kendiliğinden HTTPS'e yükseltildi ve yüklendi. Sonuç: sayfa açılıyor, kapak görünüyor, ama veri gelmiyor. Development'ta her şey http://localhost üzerinden çalıştığı için bu hata ancak production'da görünür.

Geçici bir yama olarak Nginx'te HTTPS server block'una add_header Content-Security-Policy "upgrade-insecure-requests" always; ekledim. Bu CSP (tarayıcının hangi kaynakları yükleyebileceğini belirleyen güvenlik header'ı) direktifi, tarayıcıya sayfadaki bütün http:// isteklerini göndermeden önce https://'ye çevirmesini söylüyor:

$ node mixed.mjs
[response] 200 https://www.bookstore.example/cover.png
[response] 200 https://www.bookstore.example/api/health
[status] ok scheme=https

Asıl düzeltme ise URL'i göreli yapmak. API aynı origin'de, Nginx /api/'yi Kestrel'e iletiyor; mutlak URL'e hiç gerek yok. apiUrl: '' ve src="/cover.png" ile yeniden build ettim, CSP header'ını da kaldırdım:

$ grep -c 'http://www.bookstore' dist/bookstore-web/browser/main-*.js
0
$ node mixed.mjs
[response] 200 https://www.bookstore.example/cover.png
[response] 200 https://www.bookstore.example/api/health
[status] ok scheme=https

API gerçekten başka bir domain'deyse, environment'ta https:// ile yaz ve CI'da build çıktısında http:// arayan bir kontrol koş. upgrade-insecure-requests'i kalıcı bir emniyet kemeri olarak tutabilirsin; ama yükseltilen adres HTTPS sunmuyorsa istek yine düşer, yani gerçek bir düzeltmenin yerini tutmaz.

Üretimde Dikkat

HSTS'i bir deploy değil, bir takvim olarak yönet. Her basamağın tarihini, max-age'ini ve bir sonraki basamağa geçme kriterini (hata oranı, kırık sayfa raporu) yaz. Bir basamakta sorun çıkarsa geri dönüş süresinin o basamağın max-age'i olduğunu bil.

Redirect ve HSTS'i dışarıdan test et. curl -sI http://... ile redirect kodunu ve Location'ı, curl -sI https://... ile Strict-Transport-Security'yi her deploy sonrası ölçen bir smoke test, config değişikliklerinin header'ı sessizce düşürmesini yakalar.

Cookie'lerde Secure'u açıkça iste. Authentication cookie'sinde ve antiforgery cookie'sinde ayrı ayrı Cookie.SecurePolicy = CookieSecurePolicy.Always ayarla; proxy ayarı bozulduğunda bile cookie HTTP'ye sızmaz. Antiforgery ve cookie flag'lerinin ayrıntıları Web Güvenliği serisinin, OWASP (web uygulama güvenliği üzerine çalışan açık topluluk) A07'yi işleyen ikinci yazısında.

API'ler için HTTP portunu kapatmak en temiz çözüm. Load balancer veya Gateway yalnızca 443 dinliyorsa, token'ın HTTP'den gitme ihtimali de ortadan kalkar. 80 portu paylaşımlıysa (aynı IP adresinde bir web sitesi de varsa) API host'u için 400 dön ve hangi istemcilerin hâlâ HTTP denediğini log'dan izle.

Sık Düşülen Tuzaklar

HSTS'i development'ta açıp localhost'u kilitlemek: HSTS host bazında çalışıyor, port bazında değil. Aynı makinede 5001'de HTTPS çalışan bir uygulama localhost için HSTS gönderdi, sonra 5000'deki düz HTTP uygulamaya gittim:

$ curl -sS --cacert lh.crt --hsts lh.txt https://localhost:5001/
app A on 5001
$ curl -sv --hsts lh.txt http://localhost:5000/ 2>&1 | grep HSTS
* Switched from HTTP to HTTPS due to HSTS => https://localhost:5000/
$ curl -sS --hsts lh.txt http://localhost:5000/
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number

Tarayıcıda da aynısı olur: bir uygulamanın header'ı yüzünden makinedeki bütün localhost uygulamaları HTTPS'e zorlanır. ASP.NET Core'un UseHsts'i bu yüzden localhost, 127.0.0.1 ve [::1]'i varsayılan olarak hariç tutuyor ve şablonda yalnızca development dışında çalışıyor. Kendi proxy'nde HSTS header'ı yazıyorsan bu korumayı sen kurmalısın.

includeSubDomains ile HTTP-only iç alt domain'i kırmak: yukarıda gördük. Envanter çıkarılmadan eklenen includeSubDomains, apex'i ziyaret eden herkeste o alt domain'leri max-age boyunca kapatır.

Nginx'te header'ın bazı path'lerde kaybolması: Nginx'te add_header, bir üst seviyeden yalnızca bulunduğun seviyede hiç add_header yoksa miras alınır. Server seviyesinde HSTS, /assets/ location'ında da yalnızca Cache-Control ekleyen bir server block'unu iki path ile denedim:

== /
Strict-Transport-Security: max-age=31536000; includeSubDomains
== /assets/main.js
Cache-Control: public, max-age=31536000, immutable

/assets/ altındaki yanıtlarda HSTS yok. Tarayıcı HSTS'i ilk HTTPS yanıtından öğrendiği için bu tek başına felaket değil; ama aynı kural CSP'yi ve diğer security header'larını da düşürür. Header'ları bir include dosyasında topla ve add_header kullanan her location'a o dosyayı da ekle.

Redirect'i 301 ile yapıp API'yi unutmak: 301 ve 302 POST'u GET'e çevirebilir. Sayfalar için 301 uygun, ama aynı kuralın arkasında form post'ları veya API çağrıları varsa ya 307/308 kullan ya da API host'unu ayrı tutup reddet.

Küçük Bir Deneme

★☆☆ Kendi sitende curl -sI http://alanadin/ ve curl -sI https://alanadin/ çalıştır. Redirect kodu ne, Location path'i ve query string'i koruyor mu, HSTS header'ı var mı ve hangi max-age ile?

★★☆ Bu yazıdaki ASP.NET Core uygulamasında AddCookie'ye options.Cookie.SecurePolicy = CookieSecurePolicy.Always ekle ve forwarded header'lar kapalıyken /account/login'i tekrar çağır. Set-Cookie nasıl değişti? Uygulama HTTP gördüğü halde cookie neden yine de çalışıyor?

★★★ Bookstore domain'i için bir HSTS yayın planı yaz: bütün alt domain'leri DNS zone'undan ve crt.sh'tan çıkar, HTTPS'i olmayanları işaretle, her basamağın tarihini ve geçiş kriterini belirle. Sonra preload'un şartlarını bu listeye uygula: bugün başvursan hangi alt domain kırılırdı?

HTTPS'i zorunlu kıldık; artık sunucunun kimliğinden eminiz. Bir sonraki yazıda soruyu tersine çeviriyoruz: istemcinin kimliğinden nasıl emin oluruz? mTLS (mutual TLS), ASP.NET Core'da client certificate authentication ve kendi internal CA'nı işletmek.