Заголовки безопасности, CORS и настройка TLS
Заголовки безопасности — периметр на стороне браузера: CSP режет XSS, HSTS убивает SSL-strip, nosniff гасит MIME-угадывание. CORS — это сервер, разрешающий межоригинные чтения; отражение Origin с credentials отдаёт куки юзеров любому сайту. Терминируй TLS 1.2+ полной цепочкой.
Пентестер открывает твой API однострочным отчётом: «CORS с любым origin и credentials». Ты пожимаешь плечами — CORS, это же та штука, которую ты включил, чтобы SPA перестал падать с ошибкой, верно? Тогда тебе показывают proof of concept. Они хостят страницу на evil.example, жертва (залогиненная в твоё приложение) её открывает, и страница делает fetch('https://api.yourapp.com/me', { credentials: 'include' }). Браузер прикрепляет сессионную куку жертвы, твой сервер отвечает Access-Control-Allow-Origin: evil.example плюс Access-Control-Allow-Credentials: true, и JavaScript атакующего читает весь JSON профиля — почту, токены, биллинг. Ни XSS, ни украденного пароля. Несколько месяцев назад ты «починил» ошибку CORS, отражая обратно любой пришедший Origin, и эта одна строка превратила каждого залогиненного пользователя в утечку данных по простому заходу на сайт.
Заголовки безопасности: каждый блокирует свою атаку
Заголовок безопасности в ответе — это директива, которую ты шлёшь на каждый ответ и которая перенастраивает собственную защиту браузера. В рантайме он ничего не стоит и это самый дешёвый слой, которым ты владеешь, но каждый заголовок связан с конкретной атакой — поставишь не тот, и получишь ложное чувство защищённости. Когда ревьюишь новый деплой, эти шесть заголовков должны быть первым, что ты проверяешь в ответе.
Content-Security-Policy— главный тяжеловес. Он говорит браузеру, из каких источников можно грузить и исполнять ресурсы, так что внедрённый XSS-нагрузкой<script>просто не запустится.script-src 'self'значит «исполняются только скрипты с моего собственного origin»; инлайновые блоки<script>и обработчикиonclick=отвергаются. Правильный способ разрешить известный инлайновый блок — это пер-ответный nonce (script-src 'nonce-r4nd0m', совпадающий с<script nonce="r4nd0m">) или хеш содержимого. Ловушка: добавление'unsafe-inline', чтобы заработал легаси-виджет, заново включает любой инлайновый скрипт — что сводит на нет весь смысл, ведь XSS-нагрузка и есть инлайновый скрипт. Строгий CSP — самое мощное средство снижения XSS, какое можно развернуть, но оно же чаще всего ломает страницу, так что выкатывай его сперва черезContent-Security-Policy-Report-Onlyи следи за отчётами о нарушениях.Strict-Transport-Security(HSTS — HTTP Strict Transport Security, принудительное использование HTTPS) говорит браузеру: «ближайшие N секунд никогда не общайся с этим хостом по HTTP — переключайся на HTTPS до отправки чего-либо». Это закрывает окно SSL-strip, где сетевой атакующий понижает первый открытый запрос и делает MITM сессии.max-age=31536000— это один год;includeSubDomainsрасширяет это на каждый поддомен;preload(после подачи в список предзагрузки браузера) значит, что самый первый запрос уже защищён, без бреши доверия-при-первом-обращении.X-Content-Type-Options: nosniffне даёт браузеру переугадывать твойContent-Type. Без него браузер может «понюхать» загруженный пользователем файл, который ты отдал какtext/plain, и решить, что это на самом деле HTML или JavaScript, и исполнить его. Один заголовок — и целый класс MIME-путаницы исчезает.- Кликджекинг — твоя страница невидимо загружена внутри
<iframe>атакующего поверх отвлекающего UI — блокируется черезContent-Security-Policy: frame-ancestors 'none'(современный контроль) или более старыйX-Frame-Options: DENY. Шлиframe-ancestors; держиX-Frame-Optionsтолько ради древних клиентов. Referrer-Policy: strict-origin-when-cross-originобрезает заголовокReferer, чтобы ты не утекал полные URL (с токенами в пути или query) на сторонние origin.
import http from "node:http";
http.createServer((req, res) => {
// raw: явно, без зависимостей, легко тонко ошибиться на масштабе
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; frame-ancestors 'none'");
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.end("ok");
}).listen(3000);import express from "express";
import helmet from "helmet";
const app = express();
// helmet ставит разумный набор (CSP, HSTS, nosniff, frame-ancestors, referrer) одной строкой.
// CSP всё равно твой — его дефолт строгий и будет блокировать инлайновые скрипты, пока не добавишь nonce.
app.use(helmet());Компромисс сосредоточен в CSP. helmet() отдаёт большинство заголовков бесплатно, но строгий CSP, который он ставит, сломает инлайновые скрипты и любой сторонний виджет, внедряющий скрипт — так что работа не в том, чтобы «включить», а в том, чтобы «перечислить свои реальные источники, добавить nonce или хеши, прогнать в report-only, затем включить enforce». Пропуск этой выкатки — вот почему команды тянутся к 'unsafe-inline' и тихо выбрасывают защиту.
CORS: сервер разрешает межоригинные чтения
Самое непонятое в CORS (Cross-Origin Resource Sharing — совместное использование ресурсов между источниками) — это то, что он не функция, которую ты включаешь, чтобы запросы заработали — это послабление браузером запрета по умолчанию. Same-Origin Policy (политика одинакового источника) — это базовая линия: страница на https://app.com может отправить запрос на https://api.com, но браузер не даст странице прочитать ответ, если отвечающий сервер явно этого не разрешит. CORS — это и есть «явно разрешит».
Для простого запроса (обычный GET/POST только с безопасными заголовками) браузер шлёт его, а затем проверяет ответ: если Access-Control-Allow-Origin совпадает с origin страницы, JavaScript может прочитать тело; если нет — запрос всё равно дошёл до твоего сервера, но браузер прячет ответ от скрипта.
Для непростого запроса — PUT/DELETE, JSON-Content-Type или кастомный заголовок вроде Authorization — браузер сперва шлёт preflight: запрос OPTIONS, несущий Access-Control-Request-Method и Access-Control-Request-Headers. Твой сервер должен ответить Access-Control-Allow-Origin, Access-Control-Allow-Methods и Access-Control-Allow-Headers, покрывающими то, что спросили. Только если preflight прошёл, браузер шлёт реальный запрос — и только тогда открывает его ответ.
▸Почему это работает
CORS контролируется браузером, а не твоим сервером, и один этот факт снимает большинство путаницы. CORS — это не механизм авторизации или контроля доступа для твоего API. Запертый Access-Control-Allow-Origin ничего не сделает против curl, мобильного приложения, вызова сервер-к-серверу или любого не-браузерного клиента — они полностью игнорируют CORS и читают всё, что вернул твой эндпоинт. CORS защищает одну конкретную вещь: он не даёт вредоносной веб-странице, запущенной в браузере жертвы, читать твои ответы с её фоновыми credentials. Реальная защита API — это всё ещё аутентификация, авторизация и rate limiting на сервере. Если ты когда-нибудь думаешь «CORS не пустит атакующих к этому эндпоинту» — остановись, не пустит.
Ловушка credentials: как «починка» CORS становится захватом аккаунта
Вот та самая история в виде механизма. Спецификация запрещает сочетать Access-Control-Allow-Origin: * с Access-Control-Allow-Credentials: true — браузер наотрез отвергает эту пару, потому что wildcard плюс куки означали бы, что любой сайт может делать аутентифицированные чтения. Так что wildcard никогда не может нести credentials, и это безопасно по дизайну.
Опасность — в той «починке», к которой тянутся команды, когда SPA нужны куки, а wildcard перестаёт работать. Они заставляют сервер отражать пришедший Origin обратно — cors({ origin: true }) или вручную res.setHeader('Access-Control-Allow-Origin', req.headers.origin) — вместе с Access-Control-Allow-Credentials: true. Теперь сервер говорит «да» любому спросившему origin, с credentials. Это функционально идентично запрещённой паре wildcard-плюс-credentials, только спецификация не может это остановить, потому что каждый отдельный ответ выглядит привязанным к origin.
// ❌ ЛОВУШКА: отражаем любой origin, с credentials. Теперь разрешён каждый сайт.
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin ?? "*"); // эхом и evil.example тоже
res.setHeader("Access-Control-Allow-Credentials", "true");
next();
});
// ✅ ФИКС: проверяем Origin по явному allow-list до отражения.
const ALLOWED = new Set(["https://app.yourapp.com", "https://admin.yourapp.com"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); // отражаем ТОЛЬКО origin из allow-list
res.setHeader("Vary", "Origin"); // кешируем корректно по origin
res.setHeader("Access-Control-Allow-Credentials", "true");
}
// нет заголовка Origin или его нет в allow-list → не шлём ничего; браузер блокирует чтение
next();
});Поток эксплойта в точности как в начале: атакующий хостит evil.example, залогиненная жертва заходит, страница делает fetch(api, { credentials: 'include' }), браузер прикрепляет сессионную куку, твой сервер-отражатель-всего возвращает Allow-Origin: evil.example + Allow-Credentials: true, и скрипт атакующего читает аутентифицированный ответ — профиль, токены, всё, что вернёт этот эндпоинт. Фикс не подлежит обсуждению: никогда не отражай произвольный origin с credentials. Проверяй по allow-list, шли Vary: Origin, чтобы общий кеш не отдал заголовок одного origin другому, и отражай только когда origin известен. (Куки SameSite — разбираются вместе с сессионной аутентификацией — это вторая, частичная линия защиты здесь, но allow-list CORS — это та, которой ты управляешь напрямую.)
Настройка TLS: терминируй её правильно, иначе заголовки не важны
Каждый заголовок выше предполагает, что соединение действительно приватно. Настройка TLS — это правильная терминация.
- Минимальная версия TLS 1.2, предпочтительно 1.3. TLS 1.0/1.1 сломаны и должны быть выключены. Помимо безопасности, 1.3 быстрее: его рукопожатие — 1-RTT против 2-RTT у 1.2, и 1.3 поддерживает 0-RTT-возобновление — измеримая задержка на каждом новом соединении. Отключи легаси-шифры и небезопасную ренеготиацию.
- Отдавай полную цепочку сертификатов. Твой leaf-сертификат должен слаться вместе со своими промежуточными. Отсутствующий промежуточный — это классическая ловушка «работает в моём браузере, ломается в проде»: браузеры часто кешируют промежуточные или подтягивают их через расширение AIA и заклеивают брешь, но многие API-клиенты, мобильные SDK и старые стеки этого не делают — они проваливают рукопожатие напрочь. Так что у тебя проходит ручная проверка и ломается у трети интеграций.
- OCSP stapling (OCSP — Online Certificate Status Protocol, протокол проверки статуса сертификата; stapling — прикрепление сервером актуального ответа CA к рукопожатию) позволяет серверу прикрепить к рукопожатию свежее подписанное доказательство отзыва, чтобы клиентам не делать отдельный (медленный, утекающий приватность, иногда падающий) вызов к CA.
- Где терминировать. Терминировать на прокси или балансировщике (nginx, ALB, Cloudflare) — частый, операционно проще выбор. Терминировать прямо в Node (
https.createServerсminVersion: 'TLSv1.2') нормально для самодостаточных сервисов. В любом случае, когда прокси терминирует и пересылает открытый текст в Node, доверяйX-Forwarded-Protoтолько от этого известного прокси — задайapp.set('trust proxy', ...)на конкретный хоп, никогда вслепую, иначе клиент может подделатьX-Forwarded-Proto: httpsи обойти твою логику «редирект на HTTPS».
import https from "node:https";
import { readFileSync } from "node:fs";
https.createServer(
{
key: readFileSync("privkey.pem"),
// fullchain = leaf + промежуточные. Отдавать только leaf — это баг отсутствующей цепочки.
cert: readFileSync("fullchain.pem"),
minVersion: "TLSv1.2", // пол; 1.3 согласуется, когда обе стороны поддерживают
},
(req, res) => res.end("secure"),
).listen(443);Твой API отдаёт публичную, неаутентифицированную ленту И первородный SPA, которому нужны сессионные чтения по куке с app.yourapp.com. Как настроить CORS?
Коллега говорит: «наш конфиг CORS запирает API так, что вызвать его может только наш SPA». Почему это неверно?
- 01Почему отражение req.headers.origin обратно в Access-Control-Allow-Origin вместе с Access-Control-Allow-Credentials: true — это уязвимость захвата аккаунта, и в чём фикс?
- 02Почему CORS НЕ защищает твой API от атакующих и почему отсутствующий промежуточный сертификат «работает в моём браузере», но ломается у многих клиентов?
Заголовки безопасности — это периметр на стороне браузера и самый дешёвый слой, которым ты владеешь, каждый связан со своей атакой: Content-Security-Policy с script-src 'self' плюс nonce или хеши — самое мощное средство снижения XSS (а 'unsafe-inline' выбрасывает эту защиту), Strict-Transport-Security с max-age=31536000; includeSubDomains; preload убивает SSL-strip, X-Content-Type-Options: nosniff гасит MIME-угадывание, frame-ancestors/X-Frame-Options останавливают кликджекинг, а Referrer-Policy — утечку URL; helmet() ставит набор, но строгий CSP требует выкатки через report-only. CORS — самый непонятый из всех: Same-Origin Policy — это дефолт, а CORS — это то, как сервер разрешает странице прочитать межоригинный ответ, контролируется браузером, а не сервером — так что он никогда не защищает твой API от не-браузерных клиентов. Фатальная ошибка — отражать произвольный Origin обратно с Access-Control-Allow-Credentials: true, что даёт любому сайту читать аутентифицированные ответы твоих пользователей; проверяй по allow-list, шли Vary: Origin и отражай только известные origin. Наконец, всё это не важно без приватного канала: терминируй TLS 1.2+ (предпочитай 1-RTT-рукопожатие 1.3), отключи легаси-шифры и ренеготиацию, прикрепи OCSP, отдавай полную цепочку сертификатов, чтобы не-браузерные клиенты не проваливали рукопожатие, и доверяй X-Forwarded-Proto только от известного прокси. Теперь, когда встретишь cors({ origin: true }) в кодовой базе или 'unsafe-inline' в CSP — ты знаешь, почему именно эти две строки сводят на нет всю защиту, которую они притворяются добавлять.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.