harden production headers and probe contract
This commit is contained in:
@@ -1,16 +1,62 @@
|
|||||||
{
|
{
|
||||||
"date": "2026-09-06",
|
"base": "http://127.0.0.1:18081",
|
||||||
"image": "ihaleist-qa:local",
|
"readOnly": true,
|
||||||
|
"checkedAt": "2026-09-06T16:53:08.051Z",
|
||||||
"checks": {
|
"checks": {
|
||||||
"nginxConfigTest": "successful",
|
"home": {
|
||||||
"apiHealth": {"status": 200, "body": {"ok": true}},
|
"status": 200,
|
||||||
"apiReady": {"status": 200, "body": {"ok": true, "ready": true, "checks": {"userData": {"ok": true}, "sharedData": {"ok": true}}}},
|
"contentType": "text/html",
|
||||||
"runtimeNpmAudit": {"high": 0, "critical": 0, "total": 0},
|
"headers": {
|
||||||
"staticSecurityHeaders": {
|
"x-content-type-options": "nosniff",
|
||||||
"x-content-type-options": "nosniff",
|
"referrer-policy": "no-referrer",
|
||||||
"referrer-policy": "no-referrer",
|
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
||||||
"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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ı.
|
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.
|
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.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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;
|
include /etc/nginx/mime.types;
|
||||||
# Vite, pdf.js worker'ını .mjs uzantısıyla üretiyor; Debian'ın 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_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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.
|
# 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.
|
# nginx varsayılanı 1 MB → 1 MB üstü her dosya 413 ile "yüklenemedi" oluyordu.
|
||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
|
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 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 = {};
|
const checks = {};
|
||||||
|
|
||||||
async function probe(path, successField = null) {
|
async function probe(path, successField = null) {
|
||||||
@@ -16,7 +22,7 @@ async function probe(path, successField = null) {
|
|||||||
const result = {
|
const result = {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
contentType: response.headers.get('content-type'),
|
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) {
|
if (successField) {
|
||||||
const raw = await response.text();
|
const raw = await response.text();
|
||||||
@@ -33,7 +39,9 @@ async function probe(path, successField = null) {
|
|||||||
|
|
||||||
checks.home = await probe('/');
|
checks.home = await probe('/');
|
||||||
checks.health = await probe('/api/health', 'ok');
|
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 missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready].some(check => check?.headers?.[name] == null));
|
||||||
const report = {
|
const report = {
|
||||||
base,
|
base,
|
||||||
@@ -45,6 +53,6 @@ const report = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await mkdir('docs/quality/evidence', { recursive: true });
|
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));
|
console.log(JSON.stringify(report));
|
||||||
if (!report.pass) process.exitCode = 1;
|
if (!report.pass) process.exitCode = 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user