harden production headers and probe contract

This commit is contained in:
2026-09-06 19:53:30 +03:00
parent e3f0192070
commit d3e8c9d6ed
4 changed files with 83 additions and 15 deletions
+57 -11
View File
@@ -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": []
}
+6
View File
@@ -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.
+8
View File
@@ -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;
+12 -4
View File
@@ -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;