revise November 2020 security articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 11:59:30 +03:00
parent 2ffa7a40eb
commit 41c3aca967
7 changed files with 840 additions and 1 deletions
+2
View File
@@ -29,6 +29,7 @@ import { revisions as july2020Revisions } from '../scripts/upgrade-2020-07.mjs';
import { revisions as august2020Revisions } from '../scripts/upgrade-2020-08.mjs';
import { revisions as september2020Revisions } from '../scripts/upgrade-2020-09.mjs';
import { revisions as october2020Revisions } from '../scripts/upgrade-2020-10.mjs';
import { revisions as november2020Revisions } from '../scripts/upgrade-2020-11.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -63,4 +64,5 @@ export const editorialRevisions = [
...august2020Revisions,
...september2020Revisions,
...october2020Revisions,
...november2020Revisions,
];
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1528" role="img" aria-labelledby="title desc">
<title id="title">Учебная матрица минимальной защиты веб-приложения</title>
<desc id="desc">Вертикальная схема связывает четыре актива учебной админки с угрозой, контролем и наблюдаемым доказательством. Это не отчёт о реальном приложении.</desc>
<style>
.title { fill: #f8fafc; font-family: Arial, sans-serif; font-size: 34px; font-weight: 700; }
.subtitle { fill: #cbd5e1; font-family: Arial, sans-serif; font-size: 22px; }
.label { fill: #475569; font-family: Arial, sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 1px; }
.body { fill: #0f172a; font-family: Arial, sans-serif; font-size: 24px; font-weight: 600; }
.small { fill: #334155; font-family: Arial, sans-serif; font-size: 21px; }
.step { fill: #ffffff; font-family: Arial, sans-serif; font-size: 22px; font-weight: 700; text-anchor: middle; }
</style>
<rect width="720" height="1528" fill="#f8fafc"/>
<rect x="0" y="0" width="720" height="198" fill="#0f172a"/>
<text x="48" y="70" class="title">Минимальный baseline</text>
<text x="48" y="112" class="title">актив → угроза → контроль → evidence</text>
<text x="48" y="156" class="subtitle">Учебная админка, ноябрь 2020</text>
<rect x="48" y="232" width="624" height="70" rx="14" fill="#e2e8f0"/>
<circle cx="82" cy="267" r="20" fill="#2563eb"/>
<text x="82" y="275" class="step">1</text>
<text x="120" y="275" class="label">АКТИВ: ИЗМЕНЕНИЕ ПРОФИЛЯ</text>
<rect x="48" y="320" width="624" height="116" rx="16" fill="#dbeafe"/>
<text x="76" y="360" class="body">Угроза: post приходит не из</text>
<text x="76" y="392" class="body">ожидаемого контекста формы</text>
<rect x="48" y="452" width="624" height="116" rx="16" fill="#dcfce7"/>
<text x="76" y="492" class="body">Контроль: origin + CSRF token</text>
<text x="76" y="524" class="body">до server-side update</text>
<rect x="48" y="584" width="624" height="92" rx="16" fill="#fef3c7"/>
<text x="76" y="625" class="body">Evidence: foreign origin → 403</text>
<text x="76" y="655" class="small">в учебной fixture</text>
<rect x="48" y="710" width="624" height="70" rx="14" fill="#e2e8f0"/>
<circle cx="82" cy="745" r="20" fill="#2563eb"/>
<text x="82" y="753" class="step">2</text>
<text x="120" y="753" class="label">АКТИВ: СЕССИЯ АДМИНИСТРАТОРА</text>
<rect x="48" y="798" width="624" height="102" rx="16" fill="#dbeafe"/>
<text x="76" y="838" class="body">Угроза: cookie доступен script</text>
<text x="76" y="870" class="body">или не привязан к HTTPS-сценарию</text>
<rect x="48" y="916" width="624" height="102" rx="16" fill="#dcfce7"/>
<text x="76" y="956" class="body">Контроль: Secure + HttpOnly</text>
<text x="76" y="988" class="body">и узкий Path=/admin</text>
<rect x="48" y="1034" width="624" height="82" rx="16" fill="#fef3c7"/>
<text x="76" y="1084" class="body">Evidence: Set-Cookie contract</text>
<rect x="48" y="1150" width="624" height="70" rx="14" fill="#e2e8f0"/>
<circle cx="82" cy="1185" r="20" fill="#2563eb"/>
<text x="82" y="1193" class="step">3</text>
<text x="120" y="1193" class="label">АКТИВ: РАЗМЕТКА ADMIN PAGE</text>
<rect x="48" y="1238" width="624" height="70" rx="16" fill="#dbeafe"/>
<text x="76" y="1282" class="body">Угроза: неожиданный content</text>
<rect x="48" y="1322" width="624" height="70" rx="16" fill="#dcfce7"/>
<text x="76" y="1366" class="body">Контроль: encoding + CSP policy</text>
<rect x="48" y="1406" width="624" height="82" rx="16" fill="#fef3c7"/>
<text x="76" y="1456" class="body">Evidence: CSP response contract</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1500" role="img" aria-labelledby="title desc">
<title id="title">Диагностика учебного security baseline</title>
<desc id="desc">Вертикальная схема показывает для четырёх симптомов причину, безопасную проверку и действие: foreign origin, пустой token, недостаточное permission и неполный ответный контракт.</desc>
<style>
.title { fill: #f8fafc; font-family: Arial, sans-serif; font-size: 34px; font-weight: 700; }
.subtitle { fill: #cbd5e1; font-family: Arial, sans-serif; font-size: 22px; }
.title2 { fill: #f8fafc; font-family: Arial, sans-serif; font-size: 30px; font-weight: 700; }
.symptom { fill: #991b1b; font-family: Arial, sans-serif; font-size: 25px; font-weight: 700; }
.label { fill: #475569; font-family: Arial, sans-serif; font-size: 19px; font-weight: 700; letter-spacing: 1px; }
.body { fill: #0f172a; font-family: Arial, sans-serif; font-size: 22px; font-weight: 600; }
.action { fill: #166534; font-family: Arial, sans-serif; font-size: 22px; font-weight: 700; }
.num { fill: #ffffff; font-family: Arial, sans-serif; font-size: 21px; font-weight: 700; text-anchor: middle; }
</style>
<rect width="720" height="1500" fill="#f8fafc"/>
<rect x="0" y="0" width="720" height="190" fill="#0f172a"/>
<text x="48" y="70" class="title">Диагностика без сканера</text>
<text x="48" y="112" class="title2">симптом → причина → проверка → действие</text>
<text x="48" y="156" class="subtitle">Учебные request objects в памяти</text>
<rect x="48" y="226" width="624" height="268" rx="18" fill="#fff1f2"/>
<circle cx="84" cy="264" r="20" fill="#dc2626"/>
<text x="84" y="272" class="num">1</text>
<text x="122" y="270" class="symptom">Foreign origin проходит</text>
<text x="76" y="316" class="label">ПРИЧИНА</text>
<text x="76" y="348" class="body">origin gate отсутствует или поздно</text>
<text x="76" y="388" class="label">ПРОВЕРКА</text>
<text x="76" y="420" class="body">other.example.test должен дать 403</text>
<text x="76" y="460" class="label">ДЕЙСТВИЕ</text>
<text x="76" y="492" class="action">вернуть gate до update</text>
<rect x="48" y="528" width="624" height="268" rx="18" fill="#fff7ed"/>
<circle cx="84" cy="566" r="20" fill="#ea580c"/>
<text x="84" y="574" class="num">2</text>
<text x="122" y="572" class="symptom">Пустой token проходит</text>
<text x="76" y="618" class="label">ПРИЧИНА</text>
<text x="76" y="650" class="body">form field не связан с controller</text>
<text x="76" y="690" class="label">ПРОВЕРКА</text>
<text x="76" y="722" class="body">пустая строка должна дать 403</text>
<text x="76" y="762" class="label">ДЕЙСТВИЕ</text>
<text x="76" y="794" class="action">сравнить token до effect</text>
<rect x="48" y="830" width="624" height="268" rx="18" fill="#fefce8"/>
<circle cx="84" cy="868" r="20" fill="#ca8a04"/>
<text x="84" y="876" class="num">3</text>
<text x="122" y="874" class="symptom">Read-роль меняет профиль</text>
<text x="76" y="920" class="label">ПРИЧИНА</text>
<text x="76" y="952" class="body">session ошибочно заменяет permission</text>
<text x="76" y="992" class="label">ПРОВЕРКА</text>
<text x="76" y="1024" class="body">profile:read должен дать 403</text>
<text x="76" y="1064" class="label">ДЕЙСТВИЕ</text>
<text x="76" y="1096" class="action">поставить profile:write gate</text>
<rect x="48" y="1132" width="624" height="298" rx="18" fill="#ecfdf5"/>
<circle cx="84" cy="1170" r="20" fill="#16a34a"/>
<text x="84" y="1178" class="num">4</text>
<text x="122" y="1176" class="symptom">Response contract неполон</text>
<text x="76" y="1222" class="label">ПРИЧИНА</text>
<text x="76" y="1254" class="body">policy не привязана к route</text>
<text x="76" y="1294" class="label">ПРОВЕРКА</text>
<text x="76" y="1326" class="body">CSP и Set-Cookie есть в карте</text>
<text x="76" y="1366" class="label">ДЕЙСТВИЕ</text>
<text x="76" y="1398" class="action">сверить server version на стенде</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1420" role="img" aria-labelledby="title desc">
<title id="title">Граница учебного запроса к административному маршруту</title>
<desc id="desc">Вертикальный путь POST запроса от браузера к прокси, проверке сессии, origin и token, проверке права, изменению состояния и ответному контракту. Внизу указана учебная fixture.</desc>
<style>
.title { fill: #f8fafc; font-family: Arial, sans-serif; font-size: 34px; font-weight: 700; }
.subtitle { fill: #cbd5e1; font-family: Arial, sans-serif; font-size: 22px; }
.card-title { fill: #0f172a; font-family: Arial, sans-serif; font-size: 27px; font-weight: 700; }
.body { fill: #334155; font-family: Arial, sans-serif; font-size: 23px; }
.gate { fill: #166534; font-family: Arial, sans-serif; font-size: 22px; font-weight: 700; }
.note { fill: #92400e; font-family: Arial, sans-serif; font-size: 21px; font-weight: 700; }
.num { fill: #ffffff; font-family: Arial, sans-serif; font-size: 22px; font-weight: 700; text-anchor: middle; }
</style>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="5" refY="5" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
</marker>
</defs>
<rect width="720" height="1420" fill="#f8fafc"/>
<rect x="0" y="0" width="720" height="188" fill="#0f172a"/>
<text x="48" y="72" class="title">Граница одного POST</text>
<text x="48" y="116" class="title">не набор несвязанных headers</text>
<text x="48" y="158" class="subtitle">training origin: admin.example.test</text>
<line x1="360" y1="330" x2="360" y2="370" stroke="#64748b" stroke-width="8" marker-end="url(#arrow)"/>
<line x1="360" y1="524" x2="360" y2="564" stroke="#64748b" stroke-width="8" marker-end="url(#arrow)"/>
<line x1="360" y1="718" x2="360" y2="758" stroke="#64748b" stroke-width="8" marker-end="url(#arrow)"/>
<line x1="360" y1="912" x2="360" y2="952" stroke="#64748b" stroke-width="8" marker-end="url(#arrow)"/>
<line x1="360" y1="1106" x2="360" y2="1146" stroke="#64748b" stroke-width="8" marker-end="url(#arrow)"/>
<rect x="48" y="220" width="624" height="110" rx="18" fill="#dbeafe"/>
<circle cx="86" cy="265" r="21" fill="#2563eb"/>
<text x="86" y="273" class="num">1</text>
<text x="126" y="270" class="card-title">HTML-форма</text>
<text x="126" y="302" class="body">POST, origin, cookie, token</text>
<rect x="48" y="414" width="624" height="110" rx="18" fill="#e0f2fe"/>
<circle cx="86" cy="459" r="21" fill="#0284c7"/>
<text x="86" y="467" class="num">2</text>
<text x="126" y="464" class="card-title">Proxy contract</text>
<text x="126" y="496" class="body">область /admin/ и response policy</text>
<rect x="48" y="608" width="624" height="110" rx="18" fill="#dcfce7"/>
<circle cx="86" cy="653" r="21" fill="#16a34a"/>
<text x="86" y="661" class="num">3</text>
<text x="126" y="658" class="card-title">Session gate</text>
<text x="126" y="690" class="gate">нет session → 401</text>
<rect x="48" y="802" width="624" height="110" rx="18" fill="#dcfce7"/>
<circle cx="86" cy="847" r="21" fill="#16a34a"/>
<text x="86" y="855" class="num">4</text>
<text x="126" y="852" class="card-title">Form context gate</text>
<text x="126" y="884" class="gate">origin + token, иначе 403</text>
<rect x="48" y="996" width="624" height="110" rx="18" fill="#dcfce7"/>
<circle cx="86" cy="1041" r="21" fill="#16a34a"/>
<text x="86" y="1049" class="num">5</text>
<text x="126" y="1046" class="card-title">Permission gate</text>
<text x="126" y="1078" class="gate">profile:write, затем update</text>
<rect x="48" y="1190" width="624" height="182" rx="18" fill="#fef3c7"/>
<text x="80" y="1242" class="card-title">Ответ и evidence</text>
<text x="80" y="1280" class="note">CSP + Set-Cookie contract</text>
<text x="80" y="1314" class="body">fixture: expected request → 204</text>
<text x="80" y="1346" class="body">три изменённых входа → 403</text>
<text x="80" y="1378" class="body">in-memory, без сети и scanner</text>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+499
View File
@@ -0,0 +1,499 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + escapeHtml(caption) + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
);
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const length = bodyText(contentHtml).length;
if (length < 5000 || length > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + length);
}
return {
...meta,
contentHtml,
};
}
const asvs402 = {
title: 'OWASP ASVS 4.0.2 — релиз 28 октября 2020 года',
url: 'https://github.com/OWASP/ASVS/releases/tag/v4.0.2_release',
note: 'историческая версия, доступная в ноябре 2020 года; задаёт проверяемые требования, но не заменяет модель угроз конкретного приложения',
};
const owaspTopTen2017 = {
title: 'OWASP Top 10:2017',
url: 'https://owasp.org/www-project-top-ten/2017/',
note: 'версия 2017 года; используется как язык для разговора о рисках, а не как готовый список настроек для любого сервера',
};
const rfc6265 = {
title: 'RFC 6265 — HTTP State Management Mechanism, апрель 2011',
url: 'https://www.rfc-editor.org/rfc/rfc6265',
note: 'описывает поля Cookie и Set-Cookie, включая атрибуты Secure, HttpOnly и Path; не превращает один cookie-атрибут в защиту от всех угроз',
};
const cspLevel2 = {
title: 'W3C Content Security Policy Level 2 — Recommendation, 15 декабря 2016 года',
url: 'https://www.w3.org/TR/2016/REC-CSP2-20161215/',
note: 'историческая спецификация CSP2: политика передаётся HTTP-заголовком и является дополнительным барьером, а не заменой validation и encoding',
};
const nginxHeaders = {
title: 'nginx: ngx_http_headers_module — add_header',
url: 'https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header',
note: 'официальная документация синтаксиса; фрагмент конфигурации применим только после сверки с версией и маршрутизацией конкретного сервера',
};
const trainingNotice = 'Все origin, cookies, токены, заголовки, имена маршрутов и результаты ниже учебные. Модуль не открывает сеть, не сканирует цели, не проверяет реальный сервер и не хранит секреты.';
export const trainingSecurityContract = Object.freeze({
origin: 'https://admin.example.test',
route: '/admin/profile',
method: 'POST',
sessionId: 'training-session-42',
csrfToken: 'training-csrf-6fd2',
permission: 'profile:write',
responseHeaders: Object.freeze({
'content-security-policy': "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
'set-cookie': 'sid=training-session-42; Path=/admin; Secure; HttpOnly',
'referrer-policy': 'same-origin',
}),
});
function reject(status, reason, checks) {
return {
allowed: false,
status,
reason,
checks,
};
}
/**
* Учебный gate для одного state-changing HTML-маршрута.
* Он не является middleware, не парсит реальный HTTP и не заменяет
* аутентификацию или авторизацию проекта.
*/
export function evaluateTrainingStateChange(request) {
const checks = {
routeMatches: request.method === trainingSecurityContract.method
&& request.path === trainingSecurityContract.route,
sessionMatches: request.sessionId === trainingSecurityContract.sessionId,
originMatches: request.origin === trainingSecurityContract.origin,
csrfMatches: request.csrfToken === trainingSecurityContract.csrfToken,
permissionMatches: request.permission === trainingSecurityContract.permission,
};
if (!checks.routeMatches) return reject(404, 'training route mismatch', checks);
if (!checks.sessionMatches) return reject(401, 'training session missing or unknown', checks);
if (!checks.originMatches) return reject(403, 'training origin mismatch', checks);
if (!checks.csrfMatches) return reject(403, 'training csrf token mismatch', checks);
if (!checks.permissionMatches) return reject(403, 'training permission mismatch', checks);
return {
allowed: true,
status: 204,
reason: 'training state change accepted',
checks,
};
}
export function createTrainingResponseHeaders() {
return { ...trainingSecurityContract.responseHeaders };
}
export function runSecurityBaselineFixture() {
const accepted = evaluateTrainingStateChange({
method: 'POST',
path: '/admin/profile',
sessionId: 'training-session-42',
origin: 'https://admin.example.test',
csrfToken: 'training-csrf-6fd2',
permission: 'profile:write',
});
const foreignOrigin = evaluateTrainingStateChange({
method: 'POST',
path: '/admin/profile',
sessionId: 'training-session-42',
origin: 'https://other.example.test',
csrfToken: 'training-csrf-6fd2',
permission: 'profile:write',
});
const missingToken = evaluateTrainingStateChange({
method: 'POST',
path: '/admin/profile',
sessionId: 'training-session-42',
origin: 'https://admin.example.test',
csrfToken: '',
permission: 'profile:write',
});
const insufficientPermission = evaluateTrainingStateChange({
method: 'POST',
path: '/admin/profile',
sessionId: 'training-session-42',
origin: 'https://admin.example.test',
csrfToken: 'training-csrf-6fd2',
permission: 'profile:read',
});
const headers = createTrainingResponseHeaders();
return {
checks: {
expectedRequestAccepted: accepted.allowed && accepted.status === 204,
foreignOriginRejected: !foreignOrigin.allowed && foreignOrigin.status === 403
&& foreignOrigin.reason === 'training origin mismatch',
missingTokenRejected: !missingToken.allowed && missingToken.status === 403
&& missingToken.reason === 'training csrf token mismatch',
insufficientPermissionRejected: !insufficientPermission.allowed && insufficientPermission.status === 403
&& insufficientPermission.reason === 'training permission mismatch',
cspContractPresent: headers['content-security-policy'].includes("default-src 'self'")
&& headers['content-security-policy'].includes("frame-ancestors 'none'"),
sessionCookieContractPresent: headers['set-cookie'].includes('Secure')
&& headers['set-cookie'].includes('HttpOnly')
&& headers['set-cookie'].includes('Path=/admin'),
},
decisions: {
accepted,
foreignOrigin,
missingToken,
insufficientPermission,
},
headers,
};
}
export function verifyFixture() {
const fixture = runSecurityBaselineFixture();
const passed = Object.values(fixture.checks).every(Boolean);
if (!passed) {
throw new Error('security baseline fixture did not preserve the documented training contract');
}
return fixture;
}
const trainingRequest = [
'POST /admin/profile HTTP/1.1',
'Host: admin.example.test',
'Origin: https://admin.example.test',
'Cookie: sid=training-session-42',
'Content-Type: application/x-www-form-urlencoded',
'',
'displayName=Alex&csrf=training-csrf-6fd2',
].join('\n');
const trainingResponse = [
'HTTP/1.1 204 No Content',
"Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
'Set-Cookie: sid=training-session-42; Path=/admin; Secure; HttpOnly',
'Referrer-Policy: same-origin',
].join('\n');
const nginxTrainingConfig = [
'# Учебный фрагмент, не готовая конфигурация production-сервера.',
'# admin.example.test — учебный origin; внешний адрес или секрет здесь не нужны.',
'location /admin/ {',
" add_header Content-Security-Policy \"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'\" always;",
' add_header Referrer-Policy "same-origin" always;',
' proxy_pass http://training_admin_upstream;',
'}',
'',
'# Атрибуты cookie выставляет приложение после успешной аутентификации:',
'# Set-Cookie: sid=...; Path=/admin; Secure; HttpOnly',
].join('\n');
const routeCode = [
'// Учебный псевдокод одного HTML-маршрута. Не middleware и не framework API.',
'const decision = evaluateTrainingStateChange({',
" method: 'POST',",
" path: '/admin/profile',",
' sessionId: request.cookie.sid,',
' origin: request.header.origin,',
' csrfToken: request.form.csrf,',
' permission: request.user.permission,',
'});',
'',
'if (!decision.allowed) return response.status(decision.status).end();',
'return updateTrainingProfile(request.form.displayName).status(204).end();',
].join('\n');
const fixtureInvocation = [
'# В памяти: сеть, browser, proxy и scanner не запускаются.',
'node web/scripts/upgrade-2020-11.mjs --verify-fixture',
'',
'# Ищем не уязвимость, а сохранение учебного контракта:',
'# expectedRequestAccepted=true',
'# foreignOriginRejected=true',
'# missingTokenRejected=true',
'# insufficientPermissionRejected=true',
'# cspContractPresent=true',
'# sessionCookieContractPresent=true',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2020-11-practice-security-baseline',
title: 'Минимальная защита старой админки: один маршрут, четыре контроля',
categories: ['Безопасность', 'Backend', 'Практика'],
cover: '/assets/editorial/2020/security-baseline-asset-threat-control-2020.svg',
excerpt: 'Не начинаем с коллекции заголовков: для учебного маршрута связываем актив, угрозу, контроль и наблюдаемое доказательство.',
readingMinutes: 14,
},
[
paragraph('Симптом у старой админки обычно выглядит безобидно: форма меняет профиль, сессия работает, а после релиза никто не может показать, какие условия действительно защищают это изменение. В настройке лежат несколько headers, в коде есть проверка пользователя, но связь между активом, угрозой и проверкой не записана. Цена такой неопределённости конкретна: команда может добавить ещё один заголовок, оставить state-changing маршрут без server-side permission или принять успешный ответ за доказательство, что посторонний запрос не изменит данные.'),
paragraph('В ноябре 2020 года я бы начал не с большого аудита и не с обещания «закрыть web security». Ниже — учебный маршрут <code>POST /admin/profile</code> на вымышленном origin <code>https://admin.example.test</code>. Его предполагаемый эффект — изменение synthetic display name; fixture останавливается раньше, на решении до эффекта. Цель — собрать маленький baseline: известная сессия, токен и origin для state change, permission на сервере, cookie contract и ограничивающий CSP header. Это не pentest, не security scan и не подтверждение production-защиты.'),
heading('Сначала называем актив и цену ошибки'),
paragraph('Актив здесь не «сайт вообще», а возможность изменить профиль из административной формы. Угроза — запрос приходит не в ожидаемом пользовательском сценарии или пользователь с действующей сессией вызывает действие без нужного права. Контроль — не одно условие, а несколько независимых: сервер узнаёт сессию, для формы сравнивает CSRF token и ожидаемый origin, затем проверяет permission перед побочным эффектом. Доказательство — фиксированный отрицательный сценарий, который получает <code>403</code>, и явный положительный сценарий, который получает <code>204</code>.'),
paragraph('Такое сужение важно. Cookie с <code>HttpOnly</code> полезен для ограничения доступа скрипта к значению cookie, но сам по себе не отвечает на вопрос, можно ли отправить нежелательное действие из браузера. Аналогично CSP ограничивает источники контента на стороне user agent, но не заменяет validation input, encoding output и authorisation в приложении. OWASP ASVS 4.0.2 уже был доступен в октябре 2020 года; я использую его как список проверяемых областей, а не как наклейку «соответствует стандарту».'),
figure('/assets/editorial/2020/security-baseline-asset-threat-control-2020.svg', 'Вертикальная матрица для учебной админки: активы «изменение профиля», «сессия» и «разметка страницы» соединены с угрозами, конкретными controls и наблюдаемыми evidence, включая 403 fixture и видимые response headers', 'Baseline становится проверяемым, когда каждому активу сопоставлены не только риск и настройка, но и короткое доказательство. Один header не заменяет permission, а один положительный ответ не доказывает отрицательный путь.'),
heading('Матрица не даёт потерять границу контроля'),
dataTable(
'Учебная матрица «актив → угроза → контроль → доказательство»',
['Актив', 'Угроза в узком маршруте', 'Контроль', 'Что считаем доказательством'],
[
['Изменение display name', 'запрос проходит без ожидаемого контекста формы', 'сравнить <code>Origin</code> и CSRF token до update', 'fixture отклоняет foreign origin и пустой token с <code>403</code>'],
['Сессия администратора', 'значение cookie доступно скрипту или посылается без HTTPS-контракта', '<code>Secure</code>, <code>HttpOnly</code>, узкий <code>Path=/admin</code>', 'учебная карта response headers содержит три атрибута'],
['Право на изменение', 'аутентифицированный пользователь пишет без роли', 'server-side <code>profile:write</code> перед эффектом', 'fixture отклоняет <code>profile:read</code> с <code>403</code>'],
['Разметка admin page', 'инъекция загружает неожиданный script или объект', 'validation/encoding в приложении плюс CSP2 policy', 'в карте headers есть <code>default-src</code>, <code>object-src</code> и <code>frame-ancestors</code>'],
],
),
paragraph('В строке про сессию <code>Path</code> — ограничение области отправки cookie, а не граница авторизации. RFC 6265 прямо описывает механизмы Cookie и Set-Cookie; из этого не следует, что cookie attribute заменяет серверную проверку права. В строке про CSP также нет обещания «остановить XSS». W3C CSP Level 2 называет policy дополнительной защитой: сначала приложение корректно работает с input и output, затем browser получает более узкий список допустимых источников.'),
heading('Один state change должен пройти через все gates'),
paragraph('Симптом «форма сработала» ничего не говорит о порядке проверок. Причина ошибки часто в том, что разработчик смешивает наличие сессии с правом на конкретное действие. Проверка должна быть простой: до вызова функции обновления собрать method, path, session, origin, token и permission; затем явно остановить маршрут на первом неподходящем условии. Действие — оставить этот порядок рядом с кодом и не переносить его в неочевидный клиентский обработчик.'),
codeBlock(routeCode),
paragraph('Это намеренно не готовое middleware и не код какого-либо framework. В учебной форме token передаётся в body, а origin сравнивается только для одного HTML state-changing маршрута. В реальном приложении надо отдельно решить, какие клиенты существуют, когда browser посылает Origin, как выдаётся token, где хранится сессия и как API отличается от формы. Но permission всё равно остаётся серверной границей: клиентский скрытый элемент, URL и кнопка не являются доказательством права.'),
heading('Headers описывают контракт ответа, а не магическую броню'),
paragraph('Симптом у конфигурации другой: header записан в wiki, но никто не знает, на каком route и в каких ответах его ждут. Причина — security policy живёт отдельно от маршрута. Проверка — сделать маленькую карту response headers и привязать её к <code>/admin/</code>, а не включать случайный набор директив глобально. Действие — перед применением сверить syntax с версией веб-сервера, инвентаризировать фактические scripts, styles и frames, а затем проверить ответ разрешённого стенда.'),
codeBlock(nginxTrainingConfig),
paragraph('В примере policy запрещает plugin content, ограничивает base URL и запрещает встраивание страницы в frame. Она также разрешает scripts только с того же origin. Это может сломать старую страницу с inline script, внешним widget или другим asset host. Поэтому фрагмент не надо копировать в production «как есть»: сначала составляют список необходимых ресурсов, фиксируют допустимый риск и проверяют выбранную версию nginx. В статье нет реального proxy, upstream или HTTP-запроса; конфигурация показывает форму контракта, а не выполненную настройку.'),
heading('Короткий HTTP-след лучше общего заявления'),
paragraph('Для маршрута полезно хранить не только код, но и один ожидаемый обмен. Симптом неопределённости — спор о том, должен ли route требовать origin, token или permission. Причина — контроль не выражен наблюдаемыми полями. Проверка — описать разрешённый учебный request и expected response headers. Действие — превратить пару в тест или безопасную ручную проверку на разрешённом стенде, когда такой стенд появится.'),
codeBlock(trainingRequest + '\n\n' + trainingResponse),
paragraph('Здесь нет реального session ID, адреса, пользователя или данных профиля. <code>admin.example.test</code> — учебный origin, а token намеренно короткий и не предназначен для защиты чего-либо вне fixture. Статус <code>204</code> означает лишь успешный исход синтетического маршрута. Нельзя из него сделать вывод, что TLS настроен, cookie реально доставляется браузером, CSP принят каждым user agent или сервер выдал headers на ошибке. Эти вопросы относятся к следующей, уже разрешённой проверке окружения.'),
heading('Fixture удерживает отрицательные сценарии рядом с маршрутом'),
paragraph('Симптом регрессии проявится, если после следующей правки один из gates перестанет участвовать в решении. Причина может быть в перестановке middleware, другом имени поля или слишком широком условии. Проверка не требует подключаться к серверу: fixture меняет по одному training input и сравнивает решение. Действие — запускать её вместе с revision и не называть результатом этой команды проверку реального browser, proxy или security perimeter.'),
codeBlock(fixtureInvocation),
paragraph('В этой fixture foreign origin, пустой token и роль <code>profile:read</code> должны завершиться <code>403</code>. Успешный synthetic request получает <code>204</code>. Отдельно проверяется только наличие заданных полей CSP и Set-Cookie в объекте response contract. Если ответ на реальном стенде будет отличаться, это не повод менять fixture под сервер: сначала нужно назвать, какая граница модели отличается, и решить, расширяем ли contract либо обнаружили ошибку rollout.'),
heading('Нумерованный маршрут для первой правки'),
orderedList([
'Выбрать один state-changing маршрут и назвать актив: какое именно состояние он меняет и кому принадлежит право на это действие.',
'Записать одну угрозу, которую можно проверить без атаки: foreign origin, пустой token, отсутствующая сессия или недостаточное permission.',
'Поставить server-side gates до побочного эффекта: session, context формы, token и permission. Не переносить permission в шаблон или JavaScript.',
'Собрать response contract: cookie attributes для HTTPS-сценария и CSP policy с ограниченной областью. До rollout сверить каждый directive с реальными assets и версией сервера.',
'Добавить положительный и отрицательные учебные сценарии. Положительный маршрут доказывает только доступность функции; отрицательные доказывают, что конкретные gates действительно останавливают запрос.',
'Записать verdict и исключения: нет ли API-клиентов, внешних origins, upload, персональных данных, rate limiting или другого актива, который требует отдельной модели угроз.',
]),
heading('Граница этой практики'),
paragraph('Этот baseline не делает приложение «безопасным». Он не проверяет TLS, password storage, account recovery, file upload, SQL injection, logging, dependency vulnerabilities, rate limit, CORS, реальные браузеры или внешний сетевой периметр. Он также не запускает nginx и не утверждает, что header дошёл до production. Его результат скромнее и полезнее: для одного учебного изменения известны актив, угроза, control и evidence. Следующий инженер сможет расширить матрицу, не выдавая эту первую точку за полный AppSec-процесс.'),
paragraph(trainingNotice),
],
[asvs402, owaspTopTen2017, rfc6265, cspLevel2, nginxHeaders],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2020-11-mechanism-security-baseline',
title: 'Граница запроса: почему baseline не сводится к набору headers',
categories: ['Безопасность', 'HTTP', 'Разбор'],
cover: '/assets/editorial/2020/security-baseline-request-boundary-2020.svg',
excerpt: 'Разбираем один state-changing запрос по слоям: сессия, контекст формы, permission, ответ и доказательство каждого контроля.',
readingMinutes: 15,
},
[
paragraph('Симптом неприятный: в release notes перечислены cookie flags и CSP, но старый <code>POST</code> всё равно меняет данные после одной проверки «пользователь залогинен». Цена — ложная уверенность. При следующей ошибке команда будет обсуждать, какой header добавить, хотя отсутствует permission, token сравнивается после update или evidence вообще не связано с конкретным route. Набор защитных слов в конфигурации не показывает, где запрос должен остановиться и кто владеет решением.'),
paragraph('Я разберу учебную границу ноября 2020 года, а не современную security platform. У нас есть одна HTML-форма на <code>https://admin.example.test</code>, синтетическая сессия и один предполагаемый эффект — изменение display name. Fixture проверяет решения до такого эффекта. Маршрут проходит через proxy contract, application gates и ответ. Внутри этой статьи нет реального HTTP client, browser, server, scanner или внешней цели. Механизм нужен, чтобы сначала увидеть связь «актив → угроза → контроль → доказательство», а потом уже обсуждать более широкий риск.'),
heading('У запроса несколько разных владельцев'),
paragraph('Сессия отвечает на вопрос «кого сервер сейчас узнаёт». Token и origin отвечают на более узкий вопрос: «похож ли этот state change на ожидаемый путь формы». Permission отвечает на вопрос «может ли этот субъект менять именно это состояние». CSP и cookie attributes описывают ограничения для ответа и user agent. Эти ответы нельзя заменить друг другом. Если есть session, но нет <code>profile:write</code>, update не должен начаться. Если есть permission, но token отсутствует, HTML-маршрут с такой моделью тоже не должен начаться.'),
paragraph('Причина путаницы в том, что все проверки часто называют «auth». Проверка против этого — назвать вход, владельца и момент. Cookie читает и возвращает user agent по правилам HTTP state management; приложение интерпретирует сессию; application code принимает решение о permission; web server добавляет policy header. Действие — вынести каждую границу в короткий contract, чтобы после изменения было видно, какой именно слой ослабили или не проверили.'),
figure('/assets/editorial/2020/security-baseline-request-boundary-2020.svg', 'Вертикальная схема учебного POST-маршрута: browser с training origin передаёт request в proxy, далее приложение проверяет session, origin и token, затем permission перед state change; ответ несёт cookie и CSP contract, а fixture фиксирует положительный и отрицательные решения', 'Схема разделяет слои. Cookie не является permission, CSP не является validation, а успешное обновление не доказывает, что запрос с неверным origin остановится.'),
heading('Карта границ для одного POST'),
dataTable(
'Граница учебного state change: кто принимает решение и чем его проверить',
['Шаг', 'Вход', 'Решение', 'Доказательство в пакете'],
[
['Маршрут', '<code>POST /admin/profile</code>', 'этот contract применим только к одной форме', 'fixture отклоняет другой path как несоответствие маршруту'],
['Сессия', '<code>sid</code> из учебного cookie', 'известен ли субъект', 'неизвестная session возвращает <code>401</code> в модели'],
['Контекст формы', '<code>Origin</code> и CSRF token', 'похоже ли действие на ожидаемый маршрут', 'foreign origin и пустой token дают <code>403</code>'],
['Авторизация', '<code>profile:write</code>', 'может ли субъект менять профиль', 'permission <code>profile:read</code> даёт <code>403</code>'],
['Ответ', 'CSP, Set-Cookie, Referrer-Policy', 'какие browser-facing ограничения должен получить маршрут', 'fixture проверяет наличие заданных полей, а не delivery реального сервера'],
],
),
paragraph('В таблице намеренно разделены status <code>401</code> и <code>403</code> модели. Это не универсальный протокол для каждого API; это способ не потерять смысл учебного решения: сессия не узнана либо субъект узнан, но одно из условий запрещает действие. Проект может выбрать другие сообщения, error shape и audit log. Главное — не менять итог на «успех» ради того, чтобы клиенту было удобнее, и не продолжать update после неудачного gate.'),
heading('Cookie contract — это транспорт состояния, не право'),
paragraph('RFC 6265 фиксирует, что server задаёт состояние через <code>Set-Cookie</code>, а user agent возвращает его в <code>Cookie</code>. Атрибут <code>Secure</code> связывает учебный cookie с HTTPS-сценарием; <code>HttpOnly</code> ограничивает доступ script к cookie; <code>Path=/admin</code> делает область отправки уже. Симптом неверной модели — считать это полноценной authorisation. Причина — cookie легко выглядит как идентичность пользователя. Проверка — найти отдельный server-side permission gate. Действие — не разрешать побочный эффект, пока этот gate не сказал «да».'),
codeBlock([
'// Карта ответа для учебного контракта; значения не отправляются в сеть.',
'const headers = createTrainingResponseHeaders();',
'',
"headers['set-cookie'];",
'// sid=training-session-42; Path=/admin; Secure; HttpOnly',
'',
"headers['content-security-policy'];",
"// default-src 'self'; script-src 'self'; object-src 'none';",
"// base-uri 'self'; frame-ancestors 'none'",
].join('\n')),
paragraph('У cookie есть и другая граница: атрибуты не защищают сервер от ошибки в назначении permission. Они не доказывают корректную реализацию logout, срок жизни сессии, rotation ID, HTTPS redirect или настройку доменов. В конкретном приложении эти вопросы требуют отдельных требований и тестов. Для автора уровня М3 важнее не перечислить всё сразу, а не выдать небольшой transport contract за готовую authentication-систему.'),
heading('CSP ограничивает контент, но не чинит источник инъекции'),
paragraph('Симптом после добавления CSP часто такой: страница перестала грузить нужный script, и policy ослабляют до широкого allowlist. Причина — до настройки никто не выписал, откуда реально приходят scripts, styles, images и frames. Проверка — начать с одной страницы и inventory её ресурсов, а не добавлять домены по ошибке в консоли. Действие — оставить policy минимальной для известного маршрута, выбрать режим развёртывания для своего сервера и отдельно чинить input validation и output encoding в приложении.'),
paragraph('CSP Level 2, опубликованный W3C в 2016 году, описывает передачу policy через HTTP header и прямо рассматривает CSP как defense in depth для content injection. Это важно для исторического тона: в 2020 году policy уже полезна, но она не даёт разрешения игнорировать серверный код. В учебном contract <code>object-src &#039;none&#039;</code> убирает plugin content, <code>base-uri &#039;self&#039;</code> ограничивает base URL, а <code>frame-ancestors &#039;none&#039;</code> запрещает встраивание. Применимость конкретных directives надо проверять на поддерживаемых user agents и настоящем наборе assets.'),
codeBlock(nginxTrainingConfig),
paragraph('Фрагмент показывает расположение policy рядом с областью <code>/admin/</code>, а не гарантирует поведение любой сборки nginx. Он не содержит реальный upstream address, certificate, secret или hostname. Перед использованием нужна версия сервера, staging-route и фактический список ресурсов. Если page зависит от inline code, внешнего analytics host или embedded partner frame, правильное действие — описать зависимость и решить, нужна ли она активу, а не молча расширить policy до любой сети.'),
heading('Токен и origin — условия одного конкретного контекста'),
paragraph('Симптом CSRF-защиты без контракта — token сравнивается в одном controller, но другой state-changing endpoint забыли включить. Причина — защита привязана к кусочку формы, а не к карте маршрутов. Проверка — выписать все изменения состояния и для каждого решить, какие клиенты его вызывают. Действие — для этой учебной HTML-формы требовать origin и token до update, а для API отдельно задокументировать свой способ аутентификации и угрозы.'),
paragraph('Origin не следует применять как универсальный заменитель token: protocol может не послать его в каждом контексте, а у API могут быть другой client и другой contract. Token также не заменяет permission. В нашей маленькой модели это два независимых условия, поэтому fixture проверяет их раздельно. Такой пример не советует угадывать стратегию для всех endpoints; он показывает, что у контекста формы есть собственные входы, и их отказ должен быть наблюдаем.'),
heading('HTTP trace и fixture проверяют разные слои'),
paragraph('HTTP trace полезен, когда backend и web-server configuration должны договориться об одном маршруте. Fixture полезна, когда нужно не потерять ветвление application code при следующем refactor. Симптом ошибки — использовать один артефакт вместо другого: читать красивый header как доказательство permission или принимать unit-level <code>403</code> за факт delivery от proxy. Проверка — хранить оба коротких примера. Действие — сначала сравнить in-memory decisions, а потом на разрешённом стенде проверить фактический response.'),
codeBlock(trainingRequest),
codeBlock(fixtureInvocation),
paragraph('В этой паре нет network operation. Raw request существует только как текстовый contract, а fixture вызывает функцию напрямую с обычными объектами. Поэтому она не тестирует HTTP parser, cookie jar, TLS, nginx, browser policy или CORS. Зато она ясно показывает, какие поля меняют решение application layer. Для следующего шага это полезнее неявного «security middleware включён».'),
heading('Нумерованный путь построения baseline'),
orderedList([
'Зафиксировать один актив и один state-changing route. Описать клиентов маршрута, но не смешивать HTML-форму с API только потому, что оба используют HTTP.',
'Разложить маршрут на владельцев решений: session, контекст формы, permission, response policy. Для каждого назвать вход и время, когда он проверяется.',
'Поставить gates перед побочным эффектом и выбрать ясный отрицательный исход. Не оставлять permission после вызова update и не считать UI-кнопку контролем.',
'Описать cookie и CSP как ответный contract. Сверить версии browser и web server, список assets и необходимость каждой директивы до enforcement.',
'Добавить учебную fixture: положительный request, foreign origin, пустой token и недостаточное permission. Сохранить причины отказа в коротком и не секретном виде.',
'Записать непокрытые зоны: реальные headers, TLS, API, upload, логи, recovery, dependency management и отдельные threat models для новых активов.',
]),
heading('Что этот механизм не умеет обещать'),
paragraph('Механизм не проводит pentest, не обнаруживает уязвимости сети и не заменяет review кода. Он не говорит, что <code>Origin</code> или CSP подходят всем integrations, и не выдаёт учебный token за криптографический secret. Он также не показывает реальную delivery chain: proxy может быть настроен иначе, application может переписать headers, browser может по-своему применить policy. Базовая ценность в другом: у одного запроса появились явно раздельные gates и факт, который можно проверить до подключения более зрелого security процесса.'),
paragraph(trainingNotice),
],
[asvs402, owaspTopTen2017, rfc6265, cspLevel2, nginxHeaders],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2020-11-field-security-baseline',
title: 'Разбор учебного baseline: как доказать контроль без сканирования',
categories: ['Безопасность', 'HTTP', 'Полевой разбор'],
cover: '/assets/editorial/2020/security-baseline-diagnosis-2020.svg',
excerpt: 'Учебный разбор трёх отказов и одного разрешённого запроса: не ищем уязвимости, а проверяем, что заявленные gates не исчезли.',
readingMinutes: 15,
},
[
paragraph('Симптом в этом разборе — release checklist говорит «CSRF и headers включены», но на вопрос «какой именно запрос мы проверили?» ответа нет. Цена не абстрактная: при первой правке маршрута token может перестать доходить до controller, permission окажется в шаблоне, а ответ на ошибке потеряет policy. Тогда документация выглядит лучше реальности, а команда узнаёт о разрыве только после чужого сообщения или случайного инцидента.'),
paragraph('Ниже не отчёт о живом приложении и не разрешённый security test. Это synthetic trace для одной формы <code>POST /admin/profile</code> на <code>admin.example.test</code>. Мы не посылаем пакеты, не запускаем browser и не перебираем endpoints. Вместо этого revision-модуль создаёт четыре учебных request object в памяти: ожидаемый, foreign origin, пустой token и недостаточное permission. Цель — увидеть, что каждый заявленный control даёт свой наблюдаемый outcome, а не доказать отсутствие всех уязвимостей.'),
heading('Собираем evidence до обсуждения настроек'),
paragraph('Для небольшого baseline нужны четыре вида evidence. Первый — карта актива: чему именно может навредить маршрут. Второй — request contract с ожидаемыми method, path, origin, token и permission. Третий — response contract с cookie attributes и CSP policy. Четвёртый — отрицательные решения, которые не дают изменению состояния пройти. Если оставить только конфигурацию, нельзя отличить «поле есть в файле» от «application gate действительно сработал».'),
paragraph('В этом месте полезно держать историческую рамку. OWASP Top 10:2017 помогает назвать риск, а ASVS 4.0.2 от 28 октября 2020 года — превратить часть разговора в проверяемые требования. Но ни один документ не знает наш учебный route. Поэтому evidence начинается с конкретного актива, а не с claim о соответствии. Условие «мы увидели <code>403</code> для foreign origin в fixture» сильнее общего «защита настроена», но всё ещё относится только к модели, не к серверу.'),
figure('/assets/editorial/2020/security-baseline-diagnosis-2020.svg', 'Вертикальная схема диагностики учебного security baseline: четыре карточки связывают симптом с причиной, безопасной проверкой и действием — foreign origin, пустой token, недостаточное permission и неполный response contract; все проверки происходят в памяти', 'Диагностика не начинается со сканера. Сначала определяем, какое условие маршрута нарушено, проверяем только это условие на synthetic request и фиксируем безопасное следующее действие.'),
heading('Матрица разбора: симптом → причина → проверка → действие'),
dataTable(
'Учебный разбор одного state-changing маршрута',
['Симптом', 'Вероятная причина', 'Безопасная проверка', 'Действие'],
[
['foreign origin не должен менять профиль', 'origin не включён в gate либо сравнивается после update', 'fixture подаёт <code>https://other.example.test</code> и ждёт <code>403</code>', 'вернуть origin gate перед побочным эффектом и записать область его применимости'],
['пустой token не должен пройти', 'форма не передала поле либо controller его не сравнил', 'fixture подаёт пустое <code>csrfToken</code> и ждёт <code>403</code>', 'проверить контракт формы и controller, не ослабляя проверку ради одного клиента'],
['пользователь без write права не меняет профиль', 'session ошибочно принята за permission', 'fixture подаёт <code>profile:read</code> и ждёт <code>403</code>', 'поставить server-side permission перед update и покрыть соседние write routes отдельно'],
['ответ обязан нести известную policy', 'headers живут отдельно от route или не описаны', 'fixture сверяет поля CSP и Set-Cookie с учебной картой', 'проверить конкретный server version и ответ разрешённого стенда до rollout'],
],
),
paragraph('Таблица не назначает severity и не делает предположение о внешнем атакующем. Она даёт узкие сигналы. Симптом — что именно ожидалось от одного route; причина — какой слой мог исчезнуть; проверка — что можно выполнить без доступа к сети; действие — куда вернуть контроль. Если fixture отказывает foreign origin, это не измеряет защиту browser или proxy. Если она разрешает expected request, это не доказывает, что token имеет хорошую entropy. Каждый результат нужно читать ровно в своей границе.'),
heading('HTTP-пример нужен для разговора между кодом и конфигурацией'),
paragraph('Симптом разрыва часто начинается на стыке команд: backend говорит «token проверен», ops говорит «headers добавлены», а browser request никто не описал. Причина — у route нет общего contract. Проверка — записать безопасный учебный request и response рядом с fixture. Действие — при появлении разрешённого тестового стенда сравнить фактический ответ с этим contract, не подменяя его production cookie или реальным user data.'),
codeBlock(trainingRequest + '\n\n' + trainingResponse),
paragraph('Заголовок <code>Origin</code> в этом trace — вход одной учебной HTML-формы, не способ защитить все endpoints. Cookie <code>sid</code> тоже синтетический; RFC 6265 полезен для смысла атрибутов, но не для выдачи готовой session architecture. CSP2 policy в response contract показывает желаемую defence in depth для страницы, но validation и output encoding живут в application code. Это разделение предотвращает типичную ошибку: пытаться починить server-side authorisation браузерным header.'),
paragraph('Если разбор показывает неполный response contract, полезно посмотреть на форму конфигурации, но не выдавать её за применённый server state. Следующий фрагмент оставляет <code>/admin/</code> явной областью policy и не содержит реального upstream, host или certificate. Проверка синтаксиса, соответствия версии nginx и фактического ответа требуют отдельного разрешённого стенда. Действие в этой статье уже: сравнить требуемые поля с fixture и зафиксировать, что интеграционная проверка ещё впереди.'),
codeBlock(nginxTrainingConfig),
heading('Фикстура проверяет инварианты, а не ищет дыры'),
paragraph('Учебная fixture должна быть скучной. Она берёт известный request, затем меняет ровно одно условие: origin, token или permission. Если после изменения решение остаётся разрешающим, contract потерян. Если меняются сразу три поля, результат трудно объяснить. Поэтому модуль возвращает четыре decisions и отдельную карту headers. У него нет URL fetch, DNS, файлов, cookie jar, browser engine, proxy process или сканера. Это сознательное ограничение не даёт назвать локальную логику security assessment.'),
codeBlock(fixtureInvocation),
paragraph('Положительный результат fixture означает шесть узких вещей: expected route допускается; foreign origin, пустой token и недостаточное permission отклоняются; в карте присутствуют CSP и cookie attributes. Он не означает, что nginx синтаксически применил фрагмент, browser получил header, все response status покрыты, настоящее cookie имеет безопасное lifetime или злоумышленник не найдёт другой endpoint. Такой verdict стоит сохранить именно с этой границей — иначе следующий читатель примет учебный unit-level check за проверку периметра.'),
heading('Как читать отказ, не превращая его в инцидент'),
paragraph('Первый случай: foreign origin неожиданно допускается. Причина обычно не в CSP: policy ответа не участвует в server-side decision. Проверка — открыть gate и убедиться, что origin сравнивается до update. Действие — вернуть проверку и добавить fixture для конкретного route. Второй случай: token пуст, но update прошёл. Причина — поле формы не связано с controller или сравнение заменено условием «если поле есть». Проверка — передать пустую строку. Действие — требовать совпадение с ожидаемым server-side значением, не логируя сам token.'),
paragraph('Третий случай: user с <code>profile:read</code> меняет профиль. Причина — UI знает о роли, а сервер нет, либо permission проверен для другого действия. Проверка — изменить только permission в synthetic request. Действие — найти настоящий владелец authorisation и поставить gate перед эффектом. Четвёртый случай: response contract не содержит <code>HttpOnly</code> или CSP directive. Причина может быть в карте конфигурации, месте добавления header или в выбранной версии сервера. Проверка — сначала поправить в памяти contract, затем при разрешении проверить один известный response на стенде.'),
heading('Нумерованный маршрут разбора'),
orderedList([
'Назвать один актив и один route. Не стартовать с «проверим сайт», если неизвестно, какое состояние защищаем.',
'Составить ожидаемый request contract без секретов: method, path, training origin, факт session, token и permission.',
'Выбрать один положительный и минимум три отрицательных случая. В каждом отрицательном случае менять одно условие, чтобы причина отказа оставалась читаемой.',
'Сверить response contract: cookie attributes, CSP directives и область применения. Не выводить из наличия header факт его доставки живым сервером.',
'Запустить fixture, сохранить decision и границу verdict. Если любой gate разрешает изменённый request, остановить rollout этого маршрута и вернуть явную проверку до эффекта.',
'Отдельно запланировать разрешённую интеграционную проверку: версия веб-сервера, HTTPS, фактические headers, browser compatibility, logs и остальные routes. Не подменять её этим пакетом.',
]),
heading('Граница и следующая зрелость'),
paragraph('Этот разбор не сканирует приложение, не выявляет CVE, не проверяет database, passwords, upload, CORS, CSP violations, clickjacking во всех browser, TLS или propagation headers. Он не использует современный AppSec dashboard и не претендует на полноценный threat model. Для ноября 2020 года это нормальная точка развития автора: вместо общих советов появляется один воспроизводимый contract и ясное доказательство каждого маленького gate. Дальше можно добавить разрешённый стенд и новые активы, но только не переписывать этот учебный результат как будто он уже проверил их.'),
paragraph(trainingNotice),
],
[asvs402, owaspTopTen2017, rfc6265, cspLevel2, nginxHeaders],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
process.stdout.write(JSON.stringify(verifyFixture()) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2020-11.mjs --print-revisions | --verify-fixture\n');
}
}