From d3e8c9d6ed9cffda653eb7164006ebf14e285e3b Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 19:53:30 +0300 Subject: [PATCH] harden production headers and probe contract --- .../evidence/qa-container-runtime.json | 68 ++++++++++++++++--- docs/quality/uygulama-durumu.md | 6 ++ nginx.conf | 8 +++ tests/live-runtime-probe.mjs | 16 +++-- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/docs/quality/evidence/qa-container-runtime.json b/docs/quality/evidence/qa-container-runtime.json index 1aa4a04..7806699 100644 --- a/docs/quality/evidence/qa-container-runtime.json +++ b/docs/quality/evidence/qa-container-runtime.json @@ -1,16 +1,62 @@ { - "date": "2026-09-06", - "image": "ihaleist-qa:local", + "base": "http://127.0.0.1:18081", + "readOnly": true, + "checkedAt": "2026-09-06T16:53:08.051Z", "checks": { - "nginxConfigTest": "successful", - "apiHealth": {"status": 200, "body": {"ok": true}}, - "apiReady": {"status": 200, "body": {"ok": true, "ready": true, "checks": {"userData": {"ok": true}, "sharedData": {"ok": true}}}}, - "runtimeNpmAudit": {"high": 0, "critical": 0, "total": 0}, - "staticSecurityHeaders": { - "x-content-type-options": "nosniff", - "referrer-policy": "no-referrer", - "permissions-policy": "camera=(), microphone=(), geolocation=()" + "home": { + "status": 200, + "contentType": "text/html", + "headers": { + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "permissions-policy": "camera=(), microphone=(), geolocation=()", + "x-frame-options": "DENY", + "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com", + "strict-transport-security": "max-age=31536000; includeSubDomains" + } + }, + "health": { + "status": 200, + "contentType": "application/json; charset=utf-8", + "headers": { + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "permissions-policy": "camera=(), microphone=(), geolocation=()", + "x-frame-options": "DENY", + "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com", + "strict-transport-security": "max-age=31536000; includeSubDomains" + }, + "json": { + "ok": true + }, + "apiSuccess": true + }, + "ready": { + "status": 200, + "contentType": "application/json; charset=utf-8", + "headers": { + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "permissions-policy": "camera=(), microphone=(), geolocation=()", + "x-frame-options": "DENY", + "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com", + "strict-transport-security": "max-age=31536000; includeSubDomains" + }, + "json": { + "ok": true, + "ready": true, + "checks": { + "userData": { + "ok": true + }, + "sharedData": { + "ok": true + } + } + }, + "apiSuccess": true } }, - "scope": "Local Docker image; no production deployment or external side effect" + "pass": true, + "missingHeaders": [] } diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 4139f33..e4420a4 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -2308,3 +2308,9 @@ Doğrulama: Beş IKN'de URL üretildi; kanıt [`qa-official-five-tenders.json`]( Salt okunur `npm run qa:live -- https://ihaleist.com` probe'u yeniden çalıştırıldı. Ana sayfa ve `/api/health` 200 ve `ok:true` döndü; `/api/ready` hâlâ 404 ve üç zorunlu güvenlik başlığı canlıda yok. Bu sonuç yerel düzeltmelerin production'a alınmadığını doğruluyor; ödeme, kimlik doğrulama veya veri yazma çağrısı yapılmadı. Kanıt [`qa-live-runtime.json`](evidence/qa-live-runtime.json) içinde güncellendi. Production deploy, reverse proxy başlıkları ve readiness yapılandırması tamamlanmadan canlı kabul kapatılamaz. + +### 2026-09-06 · Üretim imajı ve güvenlik başlıkları kabulü + +Üretim Docker imajı yerelde build edilip nginx yapılandırması `nginx -t` ile doğrulandı. Container içinde `/api/ready` ve `/api/health` 200 döndü; ana sayfa ve API yanıtlarında `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-Frame-Options`, CSP ve HSTS başlıkları görüldü. Node ve nginx aynı başlığı iki kez göndermesin diye proxy yanıtlarında üç ortak başlık gizleniyor. Live probe artık readiness için doğru `{ok:true}` sözleşmesini denetliyor ve kanıt dosyası yolu ortam değişkeniyle ayrılabiliyor. + +Doğrulama: yerel container probe `pass:true`; kanıt [`qa-container-runtime.json`](evidence/qa-container-runtime.json) içinde. Bu yalnız yerel imaj kabulüdür; `https://ihaleist.com` üzerinde deploy edilene kadar production sonucu değişmez. diff --git a/nginx.conf b/nginx.conf index d7ce6b1..a4c84c7 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,6 +9,9 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Frame-Options "DENY" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; include /etc/nginx/mime.types; # Vite, pdf.js worker'ını .mjs uzantısıyla üretiyor; Debian'ın mime.types @@ -24,6 +27,11 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # Node and nginx share the same security policy. Keep one canonical + # header value at the edge instead of returning comma-duplicated values. + proxy_hide_header X-Content-Type-Options; + proxy_hide_header Referrer-Policy; + proxy_hide_header Permissions-Policy; # Belge yükleme: arayüz 10 MB'a izin veriyor, base64 ile ~13,4 MB gövde. # nginx varsayılanı 1 MB → 1 MB üstü her dosya 413 ile "yüklenemedi" oluyordu. client_max_body_size 20m; diff --git a/tests/live-runtime-probe.mjs b/tests/live-runtime-probe.mjs index 2fd52b7..f7e4215 100644 --- a/tests/live-runtime-probe.mjs +++ b/tests/live-runtime-probe.mjs @@ -4,8 +4,14 @@ import { mkdir, writeFile } from 'node:fs/promises'; const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, ''); +const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json'; const timeoutMs = 15_000; -const requiredHeaders = ['x-content-type-options', 'referrer-policy', 'permissions-policy']; +const requiredHeaders = [ + 'x-content-type-options', 'referrer-policy', 'permissions-policy', + 'x-frame-options', 'content-security-policy', + ...(base.startsWith('https://') ? ['strict-transport-security'] : []), +]; +const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])]; const checks = {}; async function probe(path, successField = null) { @@ -16,7 +22,7 @@ async function probe(path, successField = null) { const result = { status: response.status, contentType: response.headers.get('content-type'), - headers: Object.fromEntries(requiredHeaders.map(name => [name, response.headers.get(name)])), + headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])), }; if (successField) { const raw = await response.text(); @@ -33,7 +39,9 @@ async function probe(path, successField = null) { checks.home = await probe('/'); checks.health = await probe('/api/health', 'ok'); -checks.ready = await probe('/api/ready', 'success'); +// /api/health and /api/ready use the same { ok: true } probe contract. The +// readiness payload also includes a separate `ready` diagnostic field. +checks.ready = await probe('/api/ready', 'ok'); const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready].some(check => check?.headers?.[name] == null)); const report = { base, @@ -45,6 +53,6 @@ const report = { }; await mkdir('docs/quality/evidence', { recursive: true }); -await writeFile('docs/quality/evidence/qa-live-runtime.json', JSON.stringify(report, null, 2) + '\n'); +await writeFile(evidencePath, JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify(report)); if (!report.pass) process.exitCode = 1;