Настраиваем HTTP-заголовки веб-сервера

Ненужные заголовки

Посмотреть заголовки веб-сервера можно командой: curl -I https://yandex.ru/ или вы можете протестировать на сайте https://http.itsoft.ru
Давайте для начала посмотрим на заголовки Яндекс и Google.

[igor@new html]$ curl -I https://yandex.ru/
HTTP/1.1 200 Ok
Accept-CH: Viewport-Width, DPR, Device-Memory, RTT, Downlink, ECT
Accept-CH-Lifetime: 31536000
Cache-Control: no-cache,no-store,max-age=0,must-revalidate
Content-Length: 178283
Content-Security-Policy: report-uri https://csp.yandex.net/csp?project=morda&from=morda.big.ru&showid=1587028014.80097.85411.54444&h=stable-morda-man-yp-628&csp=new&date=20200416&yandexuid=4225126811587028014;script-src 'self' 'unsafe-inline' https://an.yandex.ru https://yastatic.net https://yandex.ru https://mc.yandex.ru;img-src https://yabs.yandex.ru https://favicon.yandex.net https://*.strm.yandex.net https://auto.ru https://an.yandex.ru https://strm.yandex.ru https://www.maximonline.ru 'self' https://thequestion.ru https://www.kinopoisk.ru https://yastatic.net https://awaps.yandex.net https://avatars.mds.yandex.net https://*.verify.yandex.ru https://mc.yandex.ru https://leonardo.edadeal.io data: https://resize.yandex.net https://yandex.ru https://mc.admetrica.ru;frame-src 'self' https://mc.yandex.ru https://yandex.ru https://yastatic.net https://st.yandexadexchange.net https://yandexadexchange.net;font-src data: https://an.yandex.ru https://yastatic.net;object-src https://avatars.mds.yandex.net;connect-src https://mc.yandex.ru https://*.cdn.ngenix.net https://games.yandex.ru https://mc.admetrica.ru https://yandex.ru https://portal-xiva.yandex.net https://auto.ru https://*.strm.yandex.net https://strm.yandex.ru https://an.yandex.ru https://mobile.yandex.net https://frontend.vh.yandex.ru https://yabs.yandex.ru https://thequestion.ru https://yastat.net https://api.market.yandex.ru 'self' https://www.kinopoisk.ru https://yastatic.net https://zen.yandex.ru https://www.maximonline.ru wss://portal-xiva.yandex.net;style-src 'unsafe-inline' https://yastatic.net;media-src https://*.strm.yandex.net https://*.cdn.ngenix.net blob:;default-src https://yastatic.net https://yastat.net
Content-Type: text/html; charset=UTF-8
Date: Thu, 16 Apr 2020 09:06:54 GMT
Expires: Thu, 16 Apr 2020 09:06:55 GMT
Last-Modified: Thu, 16 Apr 2020 09:06:55 GMT
P3P: policyref="/w3c/p3p.xml", CP="NON DSP ADM DEV PSD IVDo OUR IND STP PHY PRE NAV UNI"
Set-Cookie: yp=1589620015.ygu.1; Expires=Sun, 14-Apr-2030 09:06:54 GMT; Domain=.yandex.ru; Path=/
Set-Cookie: mda=0; Expires=Fri, 14-Aug-2020 09:06:54 GMT; Domain=.yandex.ru; Path=/
Set-Cookie: yandex_gid=213; Expires=Sat, 16-May-2020 09:06:54 GMT; Domain=.yandex.ru; Path=/
Set-Cookie: yandexuid=4225126811587028014; Expires=Sun, 14-Apr-2030 09:06:54 GMT; Domain=.yandex.ru; Path=/
Set-Cookie: i=8UHqdTah3NgMTINJMRGysflDelT4++ntHkOo85SGs6jvHhQQwro524Vs41lApmiL/8bGRA1ZejVwI0VKHneCfAPOMrg=; Expires=Sun, 14-Apr-2030 09:06:54 GMT; Domain=.yandex.ru; Path=/; Secure; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Yandex-Sdch-Disable: 1


[igor@new html]$ curl -I https://google.com/
HTTP/1.1 301 Moved Permanently
Location: https://www.google.com/
Content-Type: text/html; charset=UTF-8
Date: Thu, 16 Apr 2020 09:08:18 GMT
Expires: Sat, 16 May 2020 09:08:18 GMT
Cache-Control: public, max-age=2592000
Server: gws
Content-Length: 220
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Alt-Svc: quic=":443"; ma=2592000; v="46,43",h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,h3-T050=":443"; ma=2592000

[igor@new html]$ curl -I https://www.google.com/
HTTP/1.1 200 OK
Date: Thu, 16 Apr 2020 09:08:35 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Set-Cookie: 1P_JAR=2020-04-16-09; expires=Sat, 16-May-2020 09:08:35 GMT; path=/; domain=.google.com; Secure
Set-Cookie: NID=202=QkgyPv5FrpwHWbJGnSC83K1Sw4wysE4IMVNbd3z83iNOheMyVdbxmJJJE0AFbs9eNH9_iKGAzqdSv6iUqfr-erOlOIap7MmRMOkqQr87iS2y_FyII7AlV5Jx-K4JC2_b8F9xyJYxPpOL2hP-81Msp_HndCCDtGSi33l4gYZ3oXU; expires=Fri, 16-Oct-2020 09:08:35 GMT; path=/; domain=.google.com; HttpOnly
Transfer-Encoding: chunked
Alt-Svc: quic=":443"; ma=2592000; v="46,43",h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,h3-T050=":443"; ma=2592000
Accept-Ranges: none
Vary: Accept-Encoding

Также вам может пригодиться утилита telnet для просмотра заголовков локального сервера, если при обращении через доменное имя отвечает прокси-сервер nginx.

[igor@new html]$ telnet 192.168.29.35 80
Trying 192.168.29.35...
Connected to 192.168.29.35.
Escape character is '^]'.
HEAD / HTTP/1.1
HOST: itsoft.ru

HTTP/1.1 200 OK
Date: Tue, 05 May 2020 07:37:23 GMT
Server: Apache/2.4.6 (CentOS)
Cache-Control: max-age=604800, public
Last-Modified: Mon, 27 Apr 2020 12:28:01 GMT
Etag: 273b847f0f077f7680a9e0d54a43c8a6
Set-Cookie: a=1144943031; expires=Wed, 06-May-2020 07:37:34 GMT; path=/
X-Robots-Tag: noindex
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block; report=/feedback/http-csp-report.php
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: strict-origin
Feature-Policy: autoplay 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; usb 'none'; vibrate 'none';
Content-Security-Policy-Report-Only: default-src 'self' data: *.googletagmanager.com *.google.com *.google-analytics.com  *.googleapis.com *.gstatic.com *.doubleclick.net mc.yandex.ru api-maps.yandex.ru yastatic.net webvisor.com *.calltouch.ru *.usedesk.ru *.youtube.com *.ytimg.com 'nonce-counters' 'report-sample'; img-src https: data: blob: ; report-uri /feedback/http-csp-report.php
Content-Type: text/html; charset=utf-8

Connection closed by foreign host.

Что мы видим?! Нет у Яндекс никаких Server: ***, X-Powered-By: PHP/5.4.16
У Google есть Server. И у Гугла есть бесполезный редирект на домен третьего уровня www, который в России давно умер. Приятно, что здесь в России больше логики, чем на Западе.
Никакого смысла сообщать какой у вас сервер и какое ПО (программное обеспечение) на нём используется нет. Наоборот, это вредно, т.к. если на сайте используется устаревшее ПО, то хакерам, автоматическим сканерам это только поможет во взломе вашего сайта. К таким заголовкам относится ещё Via, который могут добавлять прокси-сервера.

Заголовок Server в Apache можно заменить перекомпилировав исходный код. Заголовок находится в файле include/ap_release.h. Но это сложно. Можно сократить информацию до просто Apache с помощью настройки ServerTokens Prod. Можно с помощью mod_security добавить в конфигурацию строку: SecServerSignature "ITSOFT"

Чтобы убрать X-Powered-By в php.ini установите set expose_php = Off или в самом php-файле вызовите header_remove("X-Powered-By");

Expires: — лишён давно смысла, т.к. в RFC2616 page-127 записано: "Note: if a response includes a Cache-Control field with the max- age directive (see section 14.9.3), that directive overrides the Expires field." Cache-Control: max-age=0 переопределяет Expires. О кешировании мы поговорим отдельно ниже.
Если expires отключён, то браузер отправляет "if-modified-since" и веб-сервер сможет ответить 304 Not Modified.
ExpiresActive Off в .htaccess отключает заголовок Expires.'

Сжатие контента

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

 <ifModule mod_deflate.c>
	AddOutputFilterByType DEFLATE text/html text/plain text/xml application/xml application/xhtml+xml text/css text/javascript application/javascript application/x-javascript
</ifModule>

Заголовки для безопасности

В настройки VirtualHost полезно добавить:
Header set X-Frame-Options DENY
Этот заголовок указывает браузеру, что сайт запрещено загружать в <frame>, <iframe>, <embed> or <object>. Это должно защитить пользователя от Clickjacking-атак.

В настройки VirtualHost полезно ещё добавить:
Header set X-Content-Type-Options nosniff
Этот заголовок указывает браузеру, что не нужно пытаться понять какой контент пришёл и исполнить его. Например, не нужно выполнять javascript, который пришёл как plain\text. Подробнее см. тут.

Следующая опция включит в браузере защиту и блокировку от XSS-атак.
Header set X-XSS-Protection "1; mode=block"
X-XSS-Protection: 1; report=https://itsoft.ru/r.php можно задать URL куда браузер отправит отчёт в случае обнаружения атаки.

Очень важная настройка запрещает браузеру коннектиться к сайту по HTTP, т.е. обязывает использоваться только HTTPS. О важности SSL было рассказано в статье Зачем нужны SSL-сертификаты.
Header set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"

Опция Referrer-Policy указывает браузеру как отправлять HTTP_REFERER. Позволяет скрыть данные QUERY_STRING. Может принимать следующие значения:

Если в нет никакой секретной информации в QUERY_STRING и вам важно, чтобы другие могли перейти на страницу с параметрами, то пойдёт и значение по-умолчанию.

Заголовок Feature-Policy указывает браузеру какими возможностями будет пользоваться сайт. К сожалению, некоторые значения можно переопределить в коде сайта. Например, так <iframe src="https://site.com" allow="vibrate">. Но заголовок сам по себе очень правильный. Мы все испытываем боль с приложениями на смартфоне, которые запрашивают привилегий доступа сильно больше, чем им реально нужно. Боль вызывают и сайты, которые хотят слать нотификации, спрашивают геолокацию, издают звуки и т.п., что нас раздражает. Сайт как и приложение смартфона должен заранее честно предупредить о том, какую информацию он собирается запрашивать, какую точно не будет запрашивать.

Заголовок имеет следующий синтакис: Feature-Policy: <directive> <allowlist>
allowlist может принимать одно или несколько из следующих значений разделённых пробелом:

Ниже идёт список возможных настроек:

Некоторые возможности (accelerometer, ambient-light-sensor, battery, display-capture, encrypted-media, execution-while-not-rendered, execution-while-out-of-viewport, fullscreen, gyroscope, layout-animations, magnetometer, navigation-override, picture-in-picture, publickey-credentials, vr, wake-lock, xr-spatial-tracking ) вообще непонятно зачем ограничивать.

Следующие опции полезно отключить, чтобы показать, что мы не вторгаемся в личное пространство посетителя и ничего не навязываем ему: autoplay, camera, geolocation, gyroscope, magnetometer, microphone, midi, payment, usb, vibrate.

Header set Feature-Policy "autoplay 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; usb 'none'; vibrate 'none';"

Для отладки и тестирования сайта полезными могут быть следующие опции: legacy-image-formats, oversized-images, unoptimized-lossy-images, unsized-media. Но на продакшене их не стоит использовать.

Header set Feature-Policy "unsized-media 'none'; oversized-images 'none'; unoptimized-lossy-images 'none'; unoptimized-lossless-images 'none';"

Заголовок Content Security Policy (CSP) это дополнительный уровень безопасности, который помогает обнаруживать и смягчать некоторые типы атак, включая межсайтовый скриптинг (XSS) и data injection атак. Данный заголовок на самом деле не только защищает от исполнения встроенного JS-кода хакерами, но главным образом дисциплинирует разработчиков не разбрасывать JS-код где попало по тексту HTML-документа, не встраивать его в HTML-теги, не использовать eval(), а хранить javascript упорядоченно в подключаемых библиотеках JS-файлов. Тем самым реализуется полное разделение HTML и javascript, и мы жёстко запрещаем плохие практики разработки. Также мы уменьшаем зависимость нашего сайта от контента на других сайтах. Неприятно же когда на странице на отображается картинка, не исполняется JS, не отображаются шрифты, по причине того, что они была подгружены с другого сайта, а теперь их там удалили. Далее рассмотрим примеры возможных настроек.

Content-Security-Policy: default-src 'self' — подгрузка файлов только с домена самого сайта, даже с поддоменов нельзя.

Content-Security-Policy: default-src 'self' *.trusted-site.ru — подгрузка файлов только с домена самого сайта и с поддоменов указанного сайта.

Content-Security-Policy: default-src 'self'; img-src *; media-src mysite.ru mysite.org; script-src trusted-lib.com — подгрузка файлов с домена самого сайта, картинок с любых сайтов (это уже плохая практика), картинок и видео с сайтов mysite.ru mysite.org, скрипты можно загружать с сайта trusted-lib.com.

Content-Security-Policy: default-src https://itsoft.ru — подгрузка файлов только с указанного домена и обязательно по протоколу https.

Content-Security-Policy: default-src 'self' *.oursite.ru; img-src * — подгрузка файлов только с домена самого сайта и поддменов *.oursite.ru, картинок откуда угодно.

Content-Security-Policy: default-src 'self'; report-uri https://itsoft.ru/report-csp.php — указывается скрипт, куда браузер будет отправлять отчёты о нарушении правил. Отчёт будет отправлен методом POST в формате JSON. В нашем скрипте можно просто перенаправить этот отчёт в почту или Slack разработчикам. На первое время можно задать правила в заголовке Content-Security-Policy-Report-Only, чтобы получать только отчеты.
Content-Security-Policy-Report-Only: default-src 'self'; report-uri https://itsoft.ru/report-csp.php

Список возможных директив:

Чтобы запретить использование объектов нужно добавить object-src 'none'. Для script-src можно использовать значение unsafe-inline и unsafe-eval. Это разрешит использование встроенных скриптов, ухудшит дисциплину разработчиков, гарантирует бардак в коде. Для style-src можно использовать unsafe-inline. Лучше эти значения не использовать для новых проектов.

Content-Security-Policy: upgrade-insecure-requests — это аналог Strict-Transport-Security.

Заголовки Last-Modified и Content-Length

Заголовки Last-Modified и Content-Length сообщаю когда документ был изменён последний раз и его размер. Поисковые роботы и браузеры отправляют в запросе заголовок If-Modified-Since, на который веб-сервер может либо выдать новую страницу либо 304 Not Modified. Для статических файлов эти параметры определяются легко.

Content-Type: image/png
Content-Length: 5922
Last-Modified: Mon, 27 Jan 2020 10:48:16 GMT

Сложнее с динамическими файлами, например с php. ETag взаимосвязан с датой последней модификации и размером содержимого. Поэтому всё же определять эти значения придётся. Во всех инструкциях по настройке кеширования сайта, которые нам удалось прочитать, этому вопросу не уделялось внимание. Предполагалось, что дата последней модификации известна. Однако это не так. И это очень непростой вопрос. Проблема в том, что дата последней моификации файла зависит от всех подключаемых php-файлов, от данных в базе данных, от заголовков запроса браузера, кук, IP-адреса посетителя, курсов валют, времени, сторонних сервисов.

Для php можно использовать следующий код, который выдаст дату последней модификации всех подключаемых файлов:

<?php function getLastModified() { $all = get_included_files(); $all = array_filter($all, "is_file"); $lms = array_map('filemtime', $all); $lm = max($lms); return $lm; } ?>

Может изменяться сам код php-файла, а данные и html-код которые он выдаёт могут не измениться. В этом случае неправильно говорить, что Last-Modified изменился. Но для простоты можно считать, что любое изменение файла ведёт к изменению Last-Modified. Для данных мы должны ориентироваться на дату изменения данных в строке таблицы. Эта задача может быть нетривиальной для страниц, где отображается множество данных из разных таблиц. Но если страница отображает одну сущность, например, конкретную новость, статью, счёт, платёж, то там дата последней модификации известна. Для страниц, где собирается множество данных из разных таблиц нужно иметь таблицу в которой lastmodified соответствующей строки будет обновляться триггером.

Можно ориентироваться на хеш от выдаваемых данных. Если хеш md5 изменился, то меняем Last-Modified. Эту задачу лучше всего поручить проксирующему серверу Nginx.

Для проверки ответов веб-сервер вы можете испоьзовать следующие команды:

Заголовки для кеширования

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

Cache-Control: no-cache,no-store,max-age=0,must-revalidate
Expires: -1
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache

На самом деле, смысла в этом никакого нет для большинства данных, только нагрузка на сервер. И не надо бояться сказать, что файл или страницу сайта можно закешировать. Во-первых, потому что браузер при следующем обращении к этому контенту при условии, что время хранения кеша просрочено спросит: "а не изменился ли ресурс с момента последнего моего запроса". И если не изменился, то веб-сервер должен вернуть заголовок 304 Not Modified и не отправлять запрашиваемый файл. Во-вторых, пользователь всегда может нажать обновить страницу и принудительно послать запрос к серверу не уточняя изменился ресурс или нет. Важно только правильно определить время хранения кеша. В решение этого вопроса можно исходить из того как часто обновляются данные и насколько это важные данные. Что случится, если посетитель увидит обновление с опозданием. Какое это разумное опоздание? Подавляющее большинство страниц в интернете чаще умирает, чем обновляется. Обновляются ленты новостей, а страница с конкретной новостью живёт вечно. Поэтому для подобных страниц можно смело выставить время жизни веша сутки или неделю, т.е. то разумное время в которое посетитель может вернуться на эту страницу или ваш сайт.

Итак, давайте рассмотрим как браузер спросит не изменился ли запрашиваемый ресурс. Для этого есть два варианта. Первый — отправить в запросе If-Modified-Since с датой предыдущего запроса. Второй — отправить в запросе If-None-Match с меткой ETag, которую он получил при предыдущем обращении.

При ответе веб-серве посылает следующие заголовки:

ETag: "1722-59d1cd83fc41f"
Cache-Control: max-age=31536000, public

ETag — является уникальной меткой, а Cache-Control сообщает можно ли хранить кеш на промежуточных прокси-сервера и сколько его можно хранить.

Итак, самый последний вопрос в который мы упёрлись — это сколько можно хранить кеш. На этот вопрос мы должны ответить в зависимости от контента и конкретных страниц нашего сайта. Для статических файлов max-age=31536000 в один год представляется вполне разумным. При этом сами файлы нужно либо подключать с параметром времени последней модификации либо изменять их имя после модификации. Для вывода параметра последней модификации файла можете использовать функцию:

function printFilenameWithTimestamp($filename) { print "$filename?t=" . date('U', filemtime($_SERVER['DOCUMENT_ROOT'] . $filename)); }

В .htaccess укажите:


    Header set Cache-Control "max-age=31536000, public"

В заголовой php-скрипта добавьте следующий код если он не обращается к базе и не зависит от других сервисов.

$last_modified = gmdate('D, d M Y H:i:s', getlastmod()); $etag = md5($last_modified); if($_SERVER['REQUEST_METHOD']=='GET') { if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && ($_SERVER['HTTP_IF_NONE_MATCH'] == $etag)) { header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified'); exit(); } if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime(substr($_SERVER['HTTP_IF_MODIFIED_SINCE'], 5))>=strtotime("$last_modified GMT")) { header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified'); exit; } } header('Cache-Control: max-age=604800, public'); header("Last-Modified: $last_modified GMT"); header("Etag: $etag");

Vary: Accept-Encoding

Vary: Accept-Encoding по сути обязательный заголовок, т.к. сейчас все веб-серверы поддерживают свжатие контента. Способ сжатия зависит от поддерживаемых браузером способов, которые браузер передаёт в заголовке Accept-Encoding. Но скорее всегого сервер добавит этот заголовок автоматически получив в запросе Accept-Encoding: gzip, deflate, br.

Итог


[root@localhost ~]# curl -I https://itsoft.ru
HTTP/1.1 200 OK
Server: nginx/1.16.0
Date: Tue, 05 May 2020 05:57:34 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Keep-Alive: timeout=200
Cache-Control: max-age=604800, public
Last-Modified: Wed, 01 Apr 2020 18:49:15 GMT
Etag: a49f2da878be351c6c73a1ec0524d8ea
Set-Cookie: a=1961012078; expires=Wed, 06-May-2020 05:57:34 GMT; path=/; secure; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block; report=/feedback/http-csp-report.php
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: same-origin
Feature-Policy: autoplay 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; usb 'none'; vibrate 'none';
Content-Security-Policy-Report-Only: default-src 'self' data: *.googletagmanager.com *.google.com *.google-analytics.com  *.googleapis.com *.gstatic.com *.doubleclick.net mc.yandex.ru api-maps.yandex.ru yastatic.net webvisor.com *.calltouch.ru *.usedesk.ru *.youtube.com *.ytimg.com 'nonce-counters' 'report-sample'; img-src https: data: blob: ; report-uri /feedback/http-csp-report.php
X-Frame-Options: SAMEORIGIN
Телеграмм ITSOFT