1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 2 из 5

HSTS и цена необратимости

Коротко

Редирект с http на https закрывает не всё: самый первый запрос всё равно уходит по http и его можно перехватить. Это и закрывает HSTS - способом, который трудно отменить.

  • Браузер запоминает домен на max-age секунд и дальше подставляет https:// сам, ещё до отправки запроса.
  • Флаг always обязателен, иначе заголовок пропадёт с ответов 4xx и 5xx.
  • Свой add_header в дочернем location уносит HSTS целиком - проверено на стенде.
  • includeSubDomains превращает обходимое предупреждение в жёсткий блок для всех поддоменов, preload практически необратим.

Если знаешь, зачем always, и понимаешь цену includeSubDomains - листай до «Теперь сам».

Сначала ответь сам

/etc/nginx/conf.d/shop.local.confhttp server
add_header Strict-Transport-Security "max-age=31536000" always;

location /api/ {
    add_header X-Request-Id $request_id always;
    proxy_pass http://app:8000;
}

Заголовок объявлен на уровне server. Придёт ли HSTS на ответы /api/?

$  команда
curl -sI https://shop.local/api/health | grep -iE 'strict|x-request'
↳  выводcurl -sI https://shop.local/api/health | grep -iE 'strict|x-request'
X-Request-Id: 4f2c...

Нет: в выводе только X-Request-Id, строки Strict-Transport-Security нет. Правило «всё или ничего» из второго раздела: свой add_header в блоке отменяет весь унаследованный набор. HSTS тихо исчез там, где появилась одна безобидная строка, и заметить это можно только запросом.

На что это похоже

Записка на двери подъезда: в этот дом ходят только через главный вход, боковая дверь больше не работает. Прочитал один раз - и целый год ходишь главным, даже не проверяя, открыта ли боковая.

Сильная сторона очевидна: злоумышленнику больше нечего перехватывать у боковой двери. Слабая - если ты повесил такую записку про весь квартал, а в соседнем доме главного входа нет, туда просто перестанут ходить.

Механизм: где именно закрывается дыра

без HSTS GET http://shop.local вот это можно перехватить 301 на https и только теперь TLS с HSTS браузер сам подставил https, http-запроса не было вовсе
HSTS убирает из сети первый незашифрованный запрос - тот, который редирект не спасает.

Правило

/etc/nginx/conf.d/shop.local.confhttp server
add_header Strict-Transport-Security "max-age=31536000" always;

max-age - в секундах; 31536000 это 365 × 24 × 60 × 60, ровно год. Три вещи, из которых состоит вся тема:

  • always обязателен. Без него заголовок не попадёт на ответы с кодами ошибок - замер из седьмого раздела показывает, что 403 и 500 теряют его, а 200 и 302 нет.
  • Ставят только в https-блоке. nginx честно отправит заголовок и по http (проверено: он там есть), но браузер по спецификации его проигнорирует - так задумано, иначе кто угодно на пути мог бы включить тебе HSTS на чужой домен.
  • Срок отсчитывается заново с каждым ответом. Пока пользователь заходит, год не кончается.

Разбор: два параметра, меняющие цену ошибки

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

includeSubDomains распространяет правило на все поддомены сразу - включая те, о которых ты забыл. Если у www.shop.local, dev.shop.local или old.shop.local нет валидного сертификата, они перестанут открываться совсем: предупреждение, которое можно было пропустить кнопкой «всё равно перейти», превращается в блок без обхода.

preload - заявка на попадание в списки, вшитые в браузеры. После неё домен ходит по https даже при первом в жизни визите, но выйти из списка сложно и долго: обновление приезжает с новой версией браузера, у части пользователей - через месяцы.

Порядок включения, который экономит нервы:

шаг что делаем цена отката
1 max-age=300 один reload
2 проверяем сертификат у всех поддоменов, включая www -
3 поднимаем до max-age=31536000 до года у уже зашедших
4 добавляем includeSubDomains то же, но на все поддомены
5 preload месяцы и заявка на исключение

Разбор: полный http-блок

/etc/nginx/conf.d/shop.local.confhttp
server {
    listen 80;
    server_name shop.local www.shop.local;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name shop.local www.shop.local;

    ssl_certificate     /etc/letsencrypt/live/shop.local/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shop.local/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000" always;

    root /var/www/shop;
    index index.html;
}

Три вещи из трёх глав раздела, собранные вместе: заголовок только в https-блоке, редирект внутри location / (а не на уровне server - иначе уедет челлендж), проверка ACME отдаётся с диска по http.

Что ломается без этого

Самый обидный случай - HSTS уже разошёлся по браузерам, а сертификат протух или сломался. Обычный сайт в этой ситуации показывает предупреждение с кнопкой «всё равно перейти»; сайт с HSTS не показывает ничего, кроме глухой ошибки. Времени на починку у тебя ровно столько, сколько пользователи готовы ждать.

Отсюда и порядок из таблицы: пока max-age маленький, ошибка стоит одного reload. После preload цена ошибки измеряется месяцами - и это единственная настройка во всём курсе, которую нельзя откатить своими силами.

Второе, что ломается тихо, - заголовок, съеденный наследованием. Проверяется одной командой:

$  команда
curl -sI https://shop.local/api/health | grep -i strict

Пусто там, где ожидался заголовок, - ищи ближайший add_header уровнем ниже. Лечится дублированием строки или add_header_inherit merge из версии 1.29.3.

Зачем это в работе

HSTS - редкий случай, когда правильная настройка делается за минуту, а неправильная стоит недели. Поэтому её включают не «заодно с https», а отдельным шагом, когда всё остальное уже работает: сертификаты продлеваются автоматически, поддомены проверены, мониторинг на срок стоит.

И проверяют, что заголовок реально доезжает до всех типов ответов:

$  команда
for p in / /api/health /nosuchpage; do curl -sI https://shop.local$p | grep -ci strict; done

Три единицы - всё в порядке. Ноль хотя бы в одной строке - есть блок, где заголовок потерялся.

Теперь сам

Сайт год отдавал max-age=31536000; includeSubDomains. Появился новый поддомен metrics.shop.local на внутреннем сервере с самоподписанным сертификатом. Сотрудники не могут его открыть: браузер не показывает кнопку «всё равно перейти». Как быстро починить?

Быстро - никак: правило уже лежит в браузерах сотрудников и живёт год с последнего визита на основной домен. Убрать includeSubDomains с боевого сайта поможет только тем, кто зайдёт на него снова, и не сразу. Рабочий выход - выдать поддомену нормальный сертификат от того же центра. Каждому вручную можно почистить запись в настройках браузера, но это не решение для парка машин.

Главное

HSTS закрывает дыру первого http-запроса: браузер запоминает домен на max-age секунд и сам ходит по https. Ставят его только на https-ответы и обязательно с always, иначе заголовок исчезает на ошибках; свой add_header в дочернем location уносит его целиком. includeSubDomains превращает обходимое предупреждение в жёсткий блок для всех поддоменов, preload практически необратим - поэтому начинают с max-age=300 и поднимают срок, лишь убедившись, что сертификаты в порядке у всех имён.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.