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

Как сервер формирует заголовки и чем отличаются две директивы

Веб-сервер собирает ответ из заголовков приложения и собственных. Директива add_header добавляет заголовок к ответу, proxy_hide_header убирает заголовок внутренней службы. Обе работают в контексте сервера и места расположения и наследуются вниз по блокам с одной особенностью.

Набор директив add_header берётся из ближайшего уровня, где он задан. Если в блоке location объявлен хотя бы один add_header, заголовки уровня сервера для этого блока не применяются. Отсюда правило: описывать заголовки на одном уровне либо выносить их в отдельный файл и подключать директивой include.

http {
    include /etc/nginx/headers-common.conf;

    server {
        listen 80;
        server_name example.ru;
        include /etc/nginx/headers-common.conf;
    }
}

Параметр always добавляет заголовок к ответам с любым кодом, включая 404 и 500. Без него заголовки прилагаются только к успешным ответам, и проверка, которая смотрит на страницу ошибки, покажет их отсутствие.

Проверка выполняется одной командой с выводом всех заголовков.

curl -sI https://example.ru/ | tr -d '\r'

Удаление технической информации из ответов

Ответ по умолчанию содержит название сервера и номер выпуска. Эти сведения полезны для поиска известных уязвимостей конкретной версии, поэтому их убирают либо приводят к нейтральному виду.

    server_tokens off;
    more_clear_headers Server;

Директива server_tokens отключает вывод номера выпуска, оставляя название продукта. Полностью заменить значение заголовка Server штатными средствами нельзя, для этого применяют сторонний модуль. Если сервер работает за обратным прокси, нейтральное значение задают на прокси, а заголовок внутренней службы скрывают.

Приложения добавляют собственные технические заголовки, их также скрывают.

    proxy_hide_header X-Powered-By;
    proxy_hide_header X-AspNet-Version;
    proxy_hide_header X-Runtime;

Отдельно проверяют заголовки, выдающие устройство системы: имя узла, путь к файлу, сведения о среде выполнения. Их видно в выводе curl, и просмотр такого вывода после каждой правки стоит сделать привычкой.

Нужные заголовки оставляют: тип содержимого, длину ответа, время изменения и метку версии. Их удаление ломает кэширование.

Заголовки защиты и строгая транспортная безопасность

Набор заголовков безопасности сложился практикой, и большинство из них принимаются браузерами в одинаковой форме.

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

Заголовок Strict-Transport-Security указывает браузеру обращаться к сайту только по шифрованному соединению заданное число секунд. Значение max-age в один год принято как минимальное для списков предварительной загрузки. Параметр includeSubDomains распространяет правило на поддомены, и включать его стоит после того, как все они работают по шифрованному соединению.

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

Особенность заголовка в его необратимости на время действия. Ошибочно выданное правило с большим сроком продолжает работать у посетителей и после удаления, а сократить срок можно только новым значением с меньшим max-age.

Заголовок X-Content-Type-Options запрещает определять тип содержимого по самому содержимому. Без него документ, отданный как текст, может быть исполнен как страница.

Заголовок X-Frame-Options ограничивает встраивание страницы в рамку на чужом сайте. Современной заменой служит директива frame-ancestors в политике содержимого: при её наличии старый заголовок игнорируется новыми браузерами, но сохраняется для совместимости.

Заголовок Referrer-Policy определяет, какая часть адреса передаётся сторонним сайтам. Значение strict-origin-when-cross-origin передаёт полный адрес внутри сайта и только имя при уходе наружу.

Заголовок Permissions-Policy заменяет прежний Feature-Policy и разрешает или запрещает доступ к возможностям браузера: камере, микрофону, определению положения. Пустые скобки означают запрет для всех источников.

Политика содержимого и порядок её ввода

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

    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

Директива default-src задаёт источник по умолчанию для всех типов содержимого, остальные уточняют отдельные категории. Значение 'self' разрешает только собственный сайт, 'none' запрещает всё, data: разрешает данные, встроенные в адрес.

Ввод политики начинают с режима наблюдения. Заголовок Content-Security-Policy-Report-Only применяет те же правила, но не блокирует загрузку, а отправляет отчёт о нарушениях.

    add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;

Такой подход позволяет собрать список реальных источников страницы и только потом включать блокировку. Прямое включение строгой политики почти всегда ломает часть функций, поскольку страницы содержат встроенные обработчики и обращения к сторонним службам.

Разрешение 'unsafe-inline' для стилей является компромиссом: оно снимает защиту от внедрения кода через атрибуты разметки. Для скриптов такое разрешение применять не стоит, вместо него используют одноразовые значения nonce, которые сервер формирует для каждого ответа. Значение подставляют в заголовок и в атрибут тега сценария, и при несовпадении браузер отказывается исполнять код. Генерировать nonce нужно на каждый запрос отдельно: постоянное значение превращает механизм в формальность.

    set $csp_nonce $request_id;
    add_header Content-Security-Policy "script-src 'nonce-$csp_nonce'" always;

Кросс-доменная изоляция и заголовки для приватных данных

Часть заголовков управляет не защитой содержимого, а тем, как браузер связывает между собой вкладки и какие ресурсы разрешено встраивать. Их применяют выборочно, поскольку строгие значения ломают встраивание сторонних элементов.

    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;

Первый заголовок изолирует вкладку от других документов, второй указывает, кто вправе загружать ресурс, третий требует явного разрешения для сторонних объектов. Третий применяют только там, где все ресурсы отдают соответствующие заголовки, иначе страница останется без внешних изображений и сценариев.

Для ответов с персональными данными правила хранения задают отдельно от статики.

    location /api/private {
        add_header Cache-Control "no-store, private" always;
        add_header Pragma "no-cache" always;
        proxy_pass http://127.0.0.1:8080;
    }

Значение no-store запрещает сохранять ответ в любом хранилище. Заголовок Pragma оставлен для совместимости со старыми клиентами и полагаться на него отдельно не стоит.

Отдельного упоминания заслуживает заголовок Set-Cookie. Признаки Secure и HttpOnly задаются на стороне приложения, но прокси может добавить недостающие свойства ко всем ответам внутренней службы.

    proxy_cookie_flags ~ secure httponly samesite=strict;

Тильда означает, что правило применяется к любым именам. Признак Secure запрещает передавать отметку по незашифрованному соединению, HttpOnly закрывает доступ из сценариев страницы, SameSite ограничивает отправку при переходах с чужих сайтов.

Перед включением политики содержимого стоит проверить её сочетание с правилами на промежуточном прокси. Два слоя могут выдавать разные значения одного заголовка, браузер применит наиболее строгое, и часть функций откажет без явных сообщений об ошибке.

Кэширование статических файлов на стороне браузера

Правила хранения задаёт заголовок Cache-Control. Для файлов, содержимое которых меняется вместе с именем, применяют долгое хранение, для документов короткое или запрет хранения.

    location ~* \.(css|js|woff2|png|jpe?g|svg|webp|ico)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000, immutable";
        access_log off;
    }

    location ~* \.html?$ {
        add_header Cache-Control "no-cache";
    }

Директива expires задаёт заголовки Expires и Cache-Control одновременно, значение 30d превращается в max-age=2592000. Параметр immutable сообщает браузеру, что файл не изменится, и он не проверяет его даже при обновлении страницы. Значение безопасно только при изменении имени файла вместе с содержимым.

Для документов применяют no-cache, которое не запрещает хранение, а требует проверки актуальности. Значение no-store запрещает хранение полностью. Отличие двух значений понимают неверно, и no-store для страниц лишает браузер условных запросов.

Параметр public разрешает хранение в общих хранилищах, private ограничивает хранение кэшем одного пользователя. Для статических файлов выбирают public, для страниц с содержимым, зависящим от посетителя, private.

Проверка актуальности, метки версии и сжатие

Когда срок хранения истёк, браузер проверяет актуальность условным запросом. Сервер отвечает кодом 304 без тела, и это экономит трафик, но не экономит обращение.

    etag on;
    if_modified_since exact;

Метку версии сервер формирует из времени изменения и размера файла. Для документов, которые собираются на лету, автоматическая метка не даёт пользы, и её задают на стороне приложения.

Проверка сжатия выполняется с указанием поддерживаемых алгоритмов.

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;
    gzip_vary on;

Заголовок Vary со значением Accept-Encoding сообщает хранилищам, что содержимое зависит от алгоритмов сжатия. Без него общее хранилище отдаст сжатый ответ клиенту, который сжатия не запрашивал.

Отдельно проверяют конфликт между слоями. Если внутренняя служба задаёт собственный Cache-Control, а прокси добавляет второй, браузер получает два значения. Убрать заголовок службы позволяет proxy_hide_header.

curl -sI https://example.ru/static/app.css | grep -i -E 'cache-control|expires|etag|vary'
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H 'If-None-Match: "значение"' https://example.ru/static/app.css

Первая команда показывает набор правил хранения, вторая проверяет ответ на условный запрос: ожидаемый код 304.

При наличии общего хранилища на пути стоит отдельно убедиться, что ответы с персональными данными в нём не задерживаются. Правило private запрещает хранение в общих кэшах, но не запрещает его в кэше браузера, поэтому для таких ответов его сочетают с no-store. Обратная проверка выполняется запросом через промежуточное хранилище: если второй одинаковый запрос обслуживается быстрее и без обращения к внутренней службе, значит ответ где-то сохранился, и место сохранения стоит найти.