open atlas
↑ К треку
Node.js с нуля до senior NODE · 07 · 04

Заголовки безопасности, CORS и настройка TLS

Заголовки безопасности — периметр на стороне браузера: CSP режет XSS, HSTS убивает SSL-strip, nosniff гасит MIME-угадывание. CORS — это сервер, разрешающий межоригинные чтения; отражение Origin с credentials отдаёт куки юзеров любому сайту. Терминируй TLS 1.2+ полной цепочкой.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Пентестер открывает твой 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». Почему это неверно?

Вспомните перед уходом
  1. 01
    Почему отражение req.headers.origin обратно в Access-Control-Allow-Origin вместе с Access-Control-Allow-Credentials: true — это уязвимость захвата аккаунта, и в чём фикс?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.