8 lines
25 KiB
JSON
8 lines
25 KiB
JSON
{
|
||
"index": 63,
|
||
"slug": "editorial-2026-04-practice-modern-web-security",
|
||
"title": "Безопасность веба начинается с границы: как доказать, что контроль прерывает атаку",
|
||
"excerpt": "CSP, CSRF-токен, CORS и проверка прав останавливают разные шаги атаки. Разбираем карту пути, рабочий пример с nonce, отрицательный тест и границы того, что действительно доказано.",
|
||
"contentHtml": "<p>Симптом знакомый: в ответе есть CSP, cookie помечена <code>HttpOnly</code>, API настроил CORS, а endpoint изменения профиля проверяет сессию. В отчёте появляется фраза «защита включена». Но команда не может ответить на более узкий вопрос: какой контроль должен прервать конкретный путь атаки и какое наблюдение это подтверждает? Без такого ответа тест заголовка легко принять за доказательство защиты данных.</p>\n<p>Цена ошибки — не только уязвимость. Во время инцидента приходится заново выяснять, проходили ли входные данные через небезопасный sink (место вывода или выполнения), отправлялась ли cookie, менялось ли состояние и на каком слое ожидали отказ. Практичнее разделить четыре объекта: <strong>asset</strong> — что защищаем, <strong>attack path</strong> — как к нему добираются, <strong>control</strong> — какой переход запрещаем, и <strong>evidence</strong> — что можно повторить и увидеть.</p>\n<h2>Начните с наблюдаемого пути, а не со списка заголовков</h2>\n<p>Возьмём учебный маршрут профиля. Пользователь вводит имя, сервер сохраняет его и выводит на странице. Если значение попадает в HTML без экранирования, атакующий может передать фрагмент, который браузер воспримет как разметку или скрипт. Последовательность выглядит так: <code>fragment → render sink → document → script execution</code>. Здесь asset — страница профиля, а опасный побочный эффект — выполнение скрипта в контексте приложения.</p>\n<p>У каждого шага свой владелец. Валидатор и экранирование отвечают за смысл и безопасный вывод значения. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе. Браузер применяет политику, но не исправляет серверный шаблон. Поэтому ответ «в заголовке есть <code>Content-Security-Policy</code>» подтверждает доставку политики, а не безопасность каждого sink.</p>\n<figure><img src='/assets/editorial/2026/modern-web-security-2026-attack-control-evidence-map.svg' alt='Карта проверки веб-атаки: недоверенный фрагмент проходит к render sink и документу, CSP nonce прерывает выполнение, а источник фрагмента и непокрытые sink остаются остаточным риском' loading='lazy' /><figcaption>Карта связывает один маршрут с одной точкой прерывания и одним наблюдением. Остаточный риск не исчезает из-за положительного теста: источник фрагмента и другие sink нужно проверять отдельно.</figcaption></figure>\n<p>Для CSRF путь другой: внешняя страница создаёт запрос, браузер может приложить cookie, сервер принимает операцию записи. CORS управляет тем, сможет ли чужой скрипт прочитать ответ; он не является универсальным запретом на отправку запроса. Нельзя перенести evidence из одного маршрута на другой только потому, что оба проходят через браузер.</p>\n<h2>Разведите контроль и доказательство</h2>\n<p>Control полезно описывать глаголом: «экранирует значение перед вставкой», «отклоняет скрипт без разрешённого nonce», «отказывает в mutation без CSRF-токена», «не разрешает субъекту изменить чужой объект». Такая формулировка сразу показывает место проверки. Формула «CSP настроена» скрывает и переход, и ожидаемый результат.</p>\n<p>Evidence должно содержать вход, маршрут, наблюдение и границу вывода. Например: «на тестовом стенде страница с маркером <code>xss_probe</code> вернула CSP-заголовок; браузер заблокировал inline script без nonce; маркер не появился в журнале выполнения». Это подтверждает одну политику и один сценарий. Оно не доказывает безопасный вывод в другом шаблоне, отсутствие XSS во всём приложении или правильность конфигурации после прокси.</p>\n<div class='table-scroll'><table><caption>Какой вопрос отвечает каждый контроль</caption><thead><tr><th scope='col'>Контроль</th><th scope='col'>Какой переход ограничивает</th><th scope='col'>Минимальное наблюдение</th><th scope='col'>Что не доказано</th></tr></thead><tbody><tr><td>Экранирование и безопасный sink</td><td>Недоверенное значение превращается в HTML или код</td><td>В DOM появляется текст, а не узел, который выполняет код</td><td>Другие шаблоны, sink и данные из другого источника</td></tr><tr><td>CSP с nonce</td><td>Скрипт без разрешённого nonce выполняется в документе</td><td>Браузер блокирует отрицательный сценарий и фиксирует нарушение политики</td><td>Что значение безопасно сформировали до вывода; поведение неподдерживаемого клиента</td></tr><tr><td>CSRF-токен</td><td>Чужой сайт отправляет state-changing запрос с cookie без доказательства намерения</td><td>Запрос без токена получает отказ, запись не меняется</td><td>XSS, права на чужой объект и маршруты без этого middleware</td></tr><tr><td>CORS</td><td>Чужой скрипт читает ответ cross-origin запроса</td><td>Браузер не отдаёт response body скрипту при запрещённом origin</td><td>Запрос без preflight не дошёл до сервера и состояние не изменилось</td></tr><tr><td>Авторизация</td><td>Субъект изменяет объект вне своей области прав</td><td>Запрос с валидной сессией к чужому объекту получает 403, запись неизменна</td><td>Наличие CSRF-защиты и безопасность клиентского кода</td></tr><tr><td><code>HttpOnly</code> / <code>SameSite</code></td><td>Чтение cookie скриптом или отправка cookie в части cross-site-контекстов</td><td>Фактические атрибуты cookie и запрос в проверяемом контексте</td><td>Полная защита от CSRF, XSS и украденной сессии</td></tr></tbody></table></div>\n<p>Таблица не заменяет тест-план. Она не говорит, что любой control обязателен для любого endpoint. Её задача — не дать одному зелёному наблюдению ответить сразу на пять разных вопросов.</p>\n<h2>Учебный пример: nonce не отменяет экранирование</h2>\n<p>Ниже — фрагмент в стиле Express для страницы, которая выводит имя. Он показывает порядок границ, но не является готовым middleware: <code>escapeHtml</code>, генерация nonce, шаблонизатор, заголовки прокси и обработка ошибок должны иметь реальные реализации и тесты. Важна последовательность: данные обезвреживаются до вывода, а политика передаётся браузеру вместе с документом.</p>\n<pre><code>import { randomBytes } from 'node:crypto';\n\napp.get('/profile', (req, res) => {\n const nonce = randomBytes(16).toString('base64');\n const safeName = escapeHtml(String(req.query.name ?? ''));\n\n res.set('Content-Security-Policy', [\n "default-src 'self'",\n "script-src 'nonce-" + nonce + "'",\n "object-src 'none'",\n "base-uri 'none'",\n ].join('; '));\n\n const html = '<h1>' + safeName + '</h1>' +\n '<script nonce="' + nonce + '">' +\n 'window.profilePageReady = true;' +\n '</script>';\n res.send(html);\n});</code></pre>\n<p>В этом коде nonce разрешает только явно помеченный скрипт текущего документа. Он не делает <code>safeName</code> безопасным: значение всё равно нужно корректно экранировать или передавать через безопасный API шаблонизатора. Если убрать экранирование, оставить только CSP и считать задачу решённой, доказательство будет неполным. W3C прямо описывает CSP как defence-in-depth для снижения последствий инъекции, а не замену проверке входа и безопасному выводу.</p>\n<p>Есть и практическое ограничение: пример не показывает, как фреймворк выставляет заголовок при ошибке, как политика проходит через CDN и как приложение обрабатывает inline-обработчики, сторонние скрипты и динамическую загрузку. Эти решения могут потребовать другие директивы и отдельные тесты. Нельзя добавлять <code>'unsafe-inline'</code> только ради того, чтобы старый тест перестал падать: это меняет саму границу, которую вы хотели проверить.</p>\n<h2>Воспроизводимая проверка на разрешённом стенде</h2>\n<p>Проверка должна отделять HTTP-наблюдение от поведения браузера. Команды ниже ничего не меняют: они скачивают страницу и заголовки с тестового URL. Подставьте адрес своего стенда, где разрешена проверка, а не production-сайта. Имя файла в примере — локальный временный артефакт ревью.</p>\n<pre><code>BASE_URL='https://staging.example.test'\nPAGE_PATH='/profile?name=xss_probe'\n\ncurl --fail-with-body -sS \\\n -D /tmp/profile.headers \\\n "$BASE_URL$PAGE_PATH" \\\n -o /tmp/profile.html\n\nrg -n -i '^content-security-policy:' /tmp/profile.headers\nrg -n 'xss_probe|nonce=' /tmp/profile.html\n\n# Ожидаем заголовок CSP и текстовый маркер.\n# Этот curl-запуск не доказывает, что браузер заблокировал код.\n! rg -n -i '<script[^>]*>[^<]*(xss_probe|alert)\\b' /tmp/profile.html</code></pre>\n<p>Для отрицательного сценария нужен браузерный тест. В него передают безопасный маркер, слушают событие нарушения CSP и проверяют, что код с неправильным nonce не создал наблюдаемого эффекта. Если тест запускается на странице с nonce, значение nonce нельзя зашивать в ожидание: сначала получите реальный документ, затем отдельно сформируйте намеренно неверный вариант. Иначе тест подтвердит только наличие атрибута, а не работу политики.</p>\n<pre><code>const violations = [];\npage.on('console', message => {\n if (message.type() === 'error') violations.push(message.text());\n});\n\nawait page.goto(baseUrl + '/profile?name=xss_probe');\nawait page.evaluate(() => {\n const script = document.createElement('script');\n script.textContent = 'window.__xssProbe = true';\n script.setAttribute('nonce', 'wrong-nonce');\n document.body.append(script);\n});\n\nconst executed = await page.evaluate(() => window.__xssProbe === true);\nif (executed) throw new Error('unexpected script execution');\nconsole.log({ executed, consoleErrors: violations.length });</code></pre>\n<p>Результат нужно записать точнее, чем «XSS закрыт»: «в браузере версии, использованной в тесте, скрипт с неверным nonce не выполнился; HTTP-ответ содержал ожидаемый CSP; безопасный вывод маркера проверен только на маршруте <code>/profile</code>». Если браузерный тест не поймал ошибку консоли, это ещё не повод ослаблять политику: консольные сообщения зависят от браузера. Надёжнее проверять отсутствие эффекта и, при необходимости, событие <code>securitypolicyviolation</code>.</p>\n<h2>Почему CORS, CSRF и права нельзя объединять в один verdict</h2>\n<p>Для cross-origin <code>fetch</code> браузер применяет CORS к чтению ответа. Некоторые запросы с безопасными для CORS методом, заголовками и типом содержимого не требуют preflight. Исторически HTML-форма и без этого механизма могла отправить запрос на другой origin, поэтому отсутствие ответа у чужого скрипта не означает отсутствие побочного эффекта на сервере. Если операция использует cookie и меняет состояние, серверу нужна отдельная CSRF-защита или эквивалентная проверка.</p>\n<p>CSRF-токен отвечает на другой вопрос: есть ли у запроса доказательство, которое внешний сайт не может получить из обычного cross-site сценария. Он не решает, имеет ли субъект право изменить объект. Для этого сервер сравнивает субъект сессии и владельца ресурса. <code>HttpOnly</code> мешает JavaScript прочитать cookie, но браузер всё равно может отправить её по правилам cookie. <code>SameSite</code> ограничивает часть отправок в cross-site-контексте, однако результат зависит от атрибута, браузера, схемы и типа навигации.</p>\n<p>Так же CSP не заменяет ни CSRF, ни авторизацию. Скрипт, который уже выполняется в доверенном контексте приложения, может вызвать разрешённую операцию; политика ресурсов не определяет права пользователя. Верный review не спрашивает «включена ли безопасность», а сопоставляет asset, угрозу, контроль, негативный тест и непроверенные соседние маршруты.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Выберите один asset и один побочный эффект: например, имя в странице профиля или изменение email.</li><li>Опишите attack path глаголами: откуда приходит значение, через какой sink проходит, где появляется документ и какое действие возможно.</li><li>Назначьте один control на один переход. Для CSP это выполнение запрещённого скрипта, для CSRF — запись без токена, для авторизации — доступ к чужому объекту.</li><li>Запишите позитивный и негативный сценарии. Позитивный показывает разрешённый контракт, негативный должен доходить до точки отказа и не менять состояние.</li><li>Проверьте HTTP отдельно: статус, заголовок, cookie-атрибуты и тело ответа. Не выдавайте отсутствие response body за отсутствие серверного эффекта.</li><li>Проверьте браузер отдельно: реальное применение CSP, выполнение скрипта, preflight и отправка credentials зависят от контекста и реализации клиента.</li><li>Сверьте покрытие: найдите соседние шаблоны, mutation, формы, фоновые обработчики и прокси, которые не участвовали в тесте.</li><li>Передайте результат четырьмя полями: asset, interruption, evidence и not-proven. Если одного поля нет, оставьте следующий шаг, а не общий verdict.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Это метод локальной проверки одного маршрута, а не сертификат безопасности приложения. Он не покрывает автоматически WebSocket, GraphQL, загрузку файлов, OAuth callback, Service Worker, фоновые очереди и native-клиенты. Для каждого канала нужно построить собственный путь и назвать границу, которая действительно применяется.</p>\n<p>Nonce и CSP зависят от того, как формируется документ и какие скрипты нужны странице. Политика из примера может сломать легитимную аналитику или inline-код. Не переносите её в другой сервис без инвентаризации ресурсов. <code>curl</code> не исполняет HTML и потому не подтверждает браузерную защиту. Браузерный тест не доказывает, что сервер безопасно выводит все данные. Тест на тестовом стенде не доказывает конфигурацию после deploy.</p>\n<p>CSRF-токен не защищает от украденной сессии или скрипта, который уже выполняется в доверенном origin. CORS не является заменой контролю записи. <code>HttpOnly</code> и <code>SameSite</code> уменьшают отдельные риски, но не являются универсальной защитой. Если команда не может назвать непроверенный sink, маршрут или контекст cookie, карта слишком широкая для честного вывода.</p>\n<h2>Критерий готовности</h2>\n<p>Для выбранной операции готово не «всё защищено», а более узкое утверждение: путь атаки записан; control стоит до опасного эффекта; позитивный сценарий работает; негативный сценарий получает ожидаемый отказ и не меняет состояние; HTTP- и браузерное наблюдения разделены; список непроверенных маршрутов сохранён. После изменения шаблона, middleware, cookie-политики, прокси или браузера этот набор нужно повторить.</p>\n<p>Такой критерий даёт команде полезный результат: видно, что остановлено, где осталось residual risk и кто должен выполнить следующий review. Если evidence подтверждает только заголовок, пишите только про заголовок. Если тест подтверждает один sink, не называйте его защитой всего приложения.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://www.w3.org/TR/2026/WD-CSP3-20260421/' target='_blank' rel='noopener noreferrer'>W3C: Content Security Policy Level 3, Working Draft от 21 апреля 2026 года</a> — определяет CSP и прямо ограничивает её роль defence-in-depth: политика снижает риск и последствия content injection, но не заменяет проверку входа и безопасное кодирование вывода. Это спецификация, а не доказательство конфигурации конкретного сайта.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS' target='_blank' rel='noopener noreferrer'>MDN: Cross-Origin Resource Sharing (CORS)</a> — описывает CORS, preflight, credentials и то, что простые запросы могут не проходить preflight. Материал объясняет поведение браузера, но не проверяет серверный побочный эффект.</li><li><a href='https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html' target='_blank' rel='noopener noreferrer'>OWASP: Cross-Site Request Forgery Prevention Cheat Sheet</a> — содержит рекомендации по synchronizer token, double-submit cookie и дополнительным проверкам origin. Рекомендация не является результатом теста конкретного endpoint.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie' target='_blank' rel='noopener noreferrer'>MDN: Set-Cookie</a> — фиксирует назначение атрибутов <code>HttpOnly</code> и <code>SameSite</code> и их влияние на доступ скрипта и отправку cookie. Фактический результат нужно проверять в целевом браузерном контексте.</li></ul>"
|
||
}
|