HTTP заголовки: что это, какие бывают и как их посмотреть

Ноутбук с двумя окнами терминала: в одном сервер, в другом запросы клиента Технологии

HTTP-заголовками (HTTP headers) называют пары Имя: значение в начале каждого запроса и ответа, через которые клиент и сервер передают дополнительную информацию о сообщении. Из заголовков запроса сервер узнаёт, к какому сайту обращаются, какой формат данных примет клиент и кто он такой; из заголовков ответа браузер получает тип и размер тела, правила кеширования, куки и требования к авторизации.

Примеры собраны в один файл headers_demo.py на Python 3.14, только стандартная библиотека: сервер на http.server и клиент на http.client работают на вашем компьютере без интернета.

Как устроен HTTP-запрос и где в нём заголовки

Сообщение HTTP/1.1 состоит из стартовой строки, заголовков, пустой строки и необязательного тела сообщения. Так выглядит запрос, который клиент из нашего примера отправил серверу:

GET /echo HTTP/1.1
Host: 127.0.0.1:8000
Accept-Encoding: identity
User-Agent: headers-demo/1.0
X-Request-Id: 42

Стартовая строка запроса содержит метод, путь к ресурсу (часть URL после домена) и версию протокола. У ответа сервера на её месте строка статуса: HTTP/1.1 200 OK, то есть версия, код ответа и короткое пояснение. Каждый заголовок заканчивается парой символов \r\n, а пустая строка сообщает, что заголовки закончились и дальше идёт тело. У этого GET-запроса тела нет, и сообщение на ней кончается; тело есть, например, у POST с данными формы или JSON.

Правила записи такие:

  • имя заголовка не зависит от регистра: Content-Type, content-type и CONTENT-TYPE означают одно и то же;
  • между именем и двоеточием пробела быть не может, сервер обязан ответить на такой запрос кодом 400 Bad Request; пробел после двоеточия допустим;
  • в HTTP/1.1 клиент обязан передать заголовок Host; запрос без него или с двумя Host сервер отклоняет с кодом 400;
  • несколько заголовков с одинаковым названием получатель может склеить в один через запятую; исключение составляет Set-Cookie: в его значении бывают запятые (например, в дате атрибута Expires), и склейка изменила бы смысл.

В HTTP/2 сообщения передаются в двоичных кадрах, а имена заголовков обязаны быть в нижнем регистре: вместо Content-Type там передаётся content-type.

Конверт с прикреплённой сверху карточкой-бланком, рядом вложенное письмо

Виды HTTP-заголовков

Деление на общие заголовки (general headers), заголовки запроса (request headers), ответа (response headers) и сущности пришло из ранних спецификаций HTTP/1.0 и HTTP/1.1. Действующий стандарт 2022 года вместо «сущности» говорит о представлении: Content-Type, Content-Encoding, Content-Length, Last-Modified, ETag и др. описывают данные в теле и встречаются в обоих направлениях.

Заголовок Где бывает Что сообщает
Host запрос домен и порт сервера; по нему один сервер обслуживает несколько сайтов
User-Agent запрос какая программа отправила запрос
Accept запрос какие типы контента клиент готов принять, например text/html или application/xml
Accept-Encoding запрос какие алгоритмы сжатия клиент понимает, например gzip
Accept-Language запрос предпочтительные языки ответа
Authorization запрос учётные данные для доступа, например токен
Cookie запрос куки, которые браузер сохранил для этого сайта
Referer запрос адрес страницы, с которой пришёл переход (имя написано с ошибкой, так в стандарте)
If-None-Match запрос ETag копии, которая уже есть у клиента
Content-Type запрос и ответ тип данных в теле и кодировка, например text/html; charset=utf-8
Content-Length запрос и ответ размер тела в байтах
Content-Encoding запрос и ответ каким алгоритмом сжато тело для передачи
Server ответ какое программное обеспечение отвечает
Date ответ время создания ответа по GMT
Cache-Control ответ (бывает и в запросе) правила кеширования
ETag, Last-Modified ответ версия ресурса и дата его последнего изменения
Location ответ URI созданного ресурса или адрес перенаправления
Set-Cookie ответ куки, которую сервер просит сохранить
WWW-Authenticate ответ какой способ аутентификации требует сервер
Vary ответ от каких заголовков запроса зависит ответ

Пример: сервер, который показывает заголовки

Сервер ниже знает три адреса: /echo возвращает заголовки запроса, которые он получил, /page отдаёт HTML-страницу с ETag, /private пускает только с токеном. Начало файла headers_demo.py:

# headers_demo.py, часть 1: учебный сервер (Python 3.14)
import hashlib
import http.client
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PAGE = "<h1>Привет!</h1>".encode("utf-8")
PAGE_ETAG = '"' + hashlib.sha256(PAGE).hexdigest()[:16] + '"'
TOKEN = "secret-123"
PORT = 8000


class DemoHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"  # по умолчанию http.server отвечает как HTTP/1.0

    def log_message(self, format, *args):
        pass  # журнал запросов выключен, чтобы не мешал выводу

    def send_body(self, status, body, content_type, extra_headers=()):
        self.send_response(status)  # строка статуса, Server и Date
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        for name, value in extra_headers:
            self.send_header(name, value)
        self.end_headers()  # пустая строка: заголовки закончились
        self.wfile.write(body)

    def do_GET(self):
        if self.path == "/echo":
            lines = [f"{name}: {value}" for name, value in self.headers.items()]
            lines.append("x-request-id = " + str(self.headers.get("x-request-id")))
            text = "\n".join(lines).encode("utf-8")
            self.send_body(200, text, "text/plain; charset=utf-8")
        elif self.path == "/page":
            if self.headers.get("If-None-Match") == PAGE_ETAG:
                self.send_response(304)
                self.send_header("ETag", PAGE_ETAG)
                self.send_header("Cache-Control", "no-cache")
                self.end_headers()
            else:
                self.send_body(200, PAGE, "text/html; charset=utf-8",
                               [("ETag", PAGE_ETAG), ("Cache-Control", "no-cache")])
        elif self.path == "/private":
            if self.headers.get("Authorization") == f"Bearer {TOKEN}":
                self.send_body(200, b'{"user": "anna"}', "application/json")
            else:
                self.send_body(401, b'{"error": "no token"}', "application/json",
                               [("WWW-Authenticate", 'Bearer realm="demo"')])
        else:
            self.send_body(404, b"not found", "text/plain; charset=utf-8")


server = ThreadingHTTPServer(("127.0.0.1", PORT), DemoHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()

Сервер работает в фоновом потоке, поэтому клиент живёт в том же файле. Если порт 8000 занят, поменяйте значение PORT: клиент в частях 2 и 5 берёт его оттуда же. Ошибки о занятом порте в Windows можно не дождаться: второй ThreadingHTTPServer на том же адресе запустился у нас без исключения.

Читайте также:  REST API

Без строки protocol_version = "HTTP/1.1" модуль http.server отвечает версией HTTP/1.0 и закрывает соединение после каждого ответа. С ней сервер поддерживает постоянное соединение (keep-alive), и тогда, по документации модуля, точный Content-Length обязателен в каждом ответе: по нему клиент определяет, где кончается тело.

Заголовки запроса и ответа на практике

Следующий фрагмент отправляет запрос со своим User-Agent и нестандартным заголовком-идентификатором X-Request-Id, печатает строку статуса, заголовки ответа и тело ответа, то есть то, что получил сервер:

# часть 2: запрос и ответ целиком
conn = http.client.HTTPConnection("127.0.0.1", PORT)
conn.request("GET", "/echo", headers={"User-Agent": "headers-demo/1.0",
                                      "X-Request-Id": "42"})
response = conn.getresponse()
print(response.status, response.reason)
for name, value in response.getheaders():
    print(f"{name}: {value}")
print()
print(response.read().decode("utf-8"))

Вывод (дата в строке Date у вас будет своя):

200 OK
Server: BaseHTTP/0.6 Python/3.14.3
Date: Mon, 12 Oct 2026 09:19:05 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 110

Host: 127.0.0.1:8000
Accept-Encoding: identity
User-Agent: headers-demo/1.0
X-Request-Id: 42
x-request-id = 42

Мы передали два заголовка, а сервер получил четыре. Host и Accept-Encoding: identity модуль http.client добавил сам; identity значит «без сжатия». Последний пункт вывода показывает, что заголовок, отправленный как X-Request-Id, сервер нашёл по имени x-request-id: объект self.headers ищет без учёта регистра. Сравнивать имена заголовков через == со строкой в своём коде не стоит, приводите их к одному регистру.

Заголовок ответа Server выдал версию Python с точностью до патча. Стандарт советует не отдавать в нём лишних подробностей: по точной версии проще подобрать известную уязвимость. В http.server это значение собирают атрибуты server_version и sys_version, и документация прямо предлагает переопределить их в своём классе. Для продакшена сам модуль не рекомендуют: в нём только базовые проверки безопасности.

Мини-компьютер со снятой крышкой: видны плата и вентилятор

Content-Type и Content-Length

Content-Type определяет, как получателю понимать тело: text/html браузер покажет как веб-страницу, image/png как изображение, text/plain выведет как текст, а application/json используется для данных API. Параметр charset=utf-8 задаёт кодировку текста. Если сервер не прислал тип, получатель вправе считать тело произвольными байтами (application/octet-stream) или попытаться угадать тип по содержимому. Такое угадывание называют MIME sniffing: оно может сделать неверный вывод о данных и создать дополнительный риск для безопасности пользователя. Тип лучше указать явно.

Content-Length считается в байтах. Строка <h1>Привет!</h1> из нашего сервера занимает 16 символов, но в UTF-8 каждая кириллическая буква весит 2 байта, и тело получается 22 байта. Если посчитать len() у строки до encode(), клиент оборвёт ответ раньше конца. Поэтому в send_body() длина считается от body уже после encode().

Тело можно передавать и без заранее известной длины, кусками: тогда вместо Content-Length ставят Transfer-Encoding: chunked. Оба заголовка в одном сообщении стоять не могут.

Рулетка натянута вдоль картонной коробки перед отправкой

Кеширование: Cache-Control, ETag и 304 Not Modified

Третий фрагмент запрашивает страницу дважды. В первый раз клиент сохраняет ETag, во второй присылает его в заголовке If-None-Match:

# часть 3: ETag и условный запрос
conn.request("GET", "/page")
first = conn.getresponse()
page = first.read()
etag = first.getheader("ETag")
print(first.status, first.getheader("Content-Type"), len(page), "байт", etag)

conn.request("GET", "/page", headers={"If-None-Match": etag})
second = conn.getresponse()
print(second.status, second.reason, len(second.read()), "байт")
print("Cache-Control:", second.getheader("Cache-Control"))
200 text/html; charset=utf-8 22 байт "a0088565a97f7f73"
304 Not Modified 0 байт
Cache-Control: no-cache

На второй запрос сервер ответил кодом 304 Not Modified и пустым телом: копия у клиента актуальна, пересылать страницу заново не нужно. В ответе 304 сервер обязан повторить те заголовки, которые отправил бы в 200: ETag, Cache-Control, Date, Vary, Expires, Content-Location. Наш сервер повторяет ETag и Cache-Control, Date добавляет send_response().

Вместо метки можно использовать дату: сервер отдаёт Last-Modified, клиент присылает её обратно в If-Modified-Since, и если ресурс с тех пор не менялся, ответ тоже будет 304. Когда в запросе есть оба заголовка, If-Modified-Since сервер обязан игнорировать.

Сравнение через == в примере упрощено. Настоящий If-None-Match может содержать список меток через запятую, звёздочку и слабые метки с приставкой W/, и сравнивать их стандарт велит по слабому правилу. В рабочем проекте эту логику берёт на себя фреймворк или веб-сервер.

Читайте также:  GraphQL

Основные директивы Cache-Control в ответе:

Директива Что значит
max-age=3600 ответ свежий 3600 секунд, после этого устаревает
no-cache хранить можно, но перед каждым использованием копию надо проверить у сервера
no-store не сохранять ни запрос, ни ответ ни в браузере, ни в прокси
private общий кеш (прокси, CDN) хранить не должен, браузер пользователя может
public явно разрешает кешировать ответ, даже если иначе было бы нельзя: например, общему кешу ответ на запрос с Authorization

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

Заголовок Expires задаёт дату, после которой ответ устаревает, в формате Fri, 01 Jan 2027 00:00:00 GMT. Если в ответе есть max-age, получатель обязан Expires игнорировать: его значение нужно только программам, которые Cache-Control не понимают. Для новых проектов хватает одного Cache-Control. Файлам, в имени которых есть хеш содержимого (app.3f9a1c.js), можно ставить max-age=31536000, это год в секундах: при изменении файла меняется имя, а с ним и адрес, и браузер запросит новый ресурс.

Курьер сверяет бирку на коробке со списком в руке у двери склада

Authorization и ответ 401

Четвёртый фрагмент обращается к закрытому ресурсу /private дважды: без заголовка Authorization и с токеном.

# часть 4: Authorization и ответ 401
for auth in ({}, {"Authorization": "Bearer secret-123"}):
    conn.request("GET", "/private", headers=auth)
    reply = conn.getresponse()
    print(reply.status, reply.getheader("WWW-Authenticate"), reply.read().decode("utf-8"))
401 Bearer realm="demo" {"error": "no token"}
200 None {"user": "anna"}

Ответ 401 Unauthorized сервер по стандарту обязан сопроводить заголовком WWW-Authenticate, в котором назван хотя бы один способ входа. Здесь это схема Bearer. Клиент повторил запрос с заголовком Authorization: Bearer secret-123 и получил 200; во втором ответе WWW-Authenticate нет, поэтому getheader() вернул None.

Bearer-токеном может воспользоваться любой, у кого он оказался. Передают его только по HTTPS и не кладут в адрес. Это может быть, например, JWT токен, который сервер выдал после входа. В REST API токен обычно передают в заголовке Authorization с каждым запросом. Если учётные данные есть, но прав не хватает, правильный код 403 Forbidden: сервер понял запрос и отказывается его выполнять.

Рука прикладывает пластиковую карту-ключ к считывателю у стеклянной двери

Коды ответа и связанные с ними заголовки

К некоторым кодам ответа стандарт требует или советует определённый заголовок.

Код Заголовок Насколько обязателен
201 Created Location с адресом нового ресурса если его нет, новым ресурсом считается адрес запроса
301 Moved Permanently, 302 Found Location с новым адресом серверу следует его прислать (SHOULD)
304 Not Modified ETag, Cache-Control, Date и др., как в 200 обязателен (MUST)
401 Unauthorized WWW-Authenticate обязателен (MUST)
405 Method Not Allowed Allow со списком методов обязателен (MUST)
429 Too Many Requests Retry-After можно (MAY)
503 Service Unavailable Retry-After, через сколько повторить можно (MAY)

Для 400 Bad Request, 403 Forbidden, 404 Not Found и 500 Internal Server Error стандарт отдельных заголовков не требует. При ошибках 4xx и 5xx серверу следует объяснить причину в теле ответа (кроме ответа на HEAD).

CORS и заголовки безопасности

Отдельная группа заголовков управляет тем, что браузер разрешит странице. Браузер добавляет к запросу на чужой домен заголовок Origin, а сервер отвечает Access-Control-Allow-Origin с этим источником или звёздочкой. Нет нужного ответа, и скрипт данных не получит, хотя простой запрос до сервера дошёл. Без отдельного разрешения скрипту видны только заголовки ответа Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified и Pragma. Подробно механизм и предварительный запрос OPTIONS описаны в статье что такое CORS.

Некоторые заголовки JavaScript в браузере выставить не может: Host, Cookie, Origin, Referer, Content-Length, Connection, Accept-Encoding и др., а также все, что начинаются с Sec- и Proxy-. Их задаёт сам браузер. Поэтому подделать Origin из fetch() нельзя, а из curl или Python можно, и сервер не должен считать его доказательством.

Минимальный набор заголовков безопасности для сайта на HTTPS:

  1. Strict-Transport-Security: max-age=31536000 велит браузеру год ходить на сайт только по HTTPS. Пришедший по обычному HTTP заголовок браузер игнорирует.
  2. Content-Security-Policy: default-src 'self' ограничивает, откуда странице можно загружать скрипты, изображения и другие ресурсы. Это второй слой защиты от XSS-атак, экранирование вывода он не заменяет.
  3. X-Content-Type-Options: nosniff запрещает браузеру использовать как скрипт ответ без JavaScript-типа и как стиль ответ без text/css.
  4. Set-Cookie с атрибутами Secure (куки уходит только по защищённому соединению) и HttpOnly (скрипты страницы её не прочитают).
Читайте также:  Что такое фреймворк

Префикс X- у своих заголовков (X-Request-Id из нашего примера) остался по привычке: с 2012 года для новых имён его использовать не рекомендуют. Приставка X- сама не делает заголовок нестандартным: X-Content-Type-Options входит в стандарт, по которому работают браузеры.

Серверная стойка с навесным замком на дверце и зелёными индикаторами

Как посмотреть HTTP-заголовки

Инструменты разработчика в браузере

По справке браузера, в Chrome инструменты разработчика открываются клавишей F12 или сочетанием Ctrl+Shift+I в Windows и Linux. Дальше вкладка Network: выберите запрос в таблице, и на вкладке Headers будут разделы General, Response Headers и Request Headers.

Монитор с открытой панелью разработчика браузера, видны полосы сетевых запросов

curl

В командной строке curl -I адрес отправляет запрос HEAD и печатает только заголовки ответа, curl -i адрес печатает заголовки вместе с телом, а curl -v адрес показывает и отправленный запрос: его строки начинаются с >, строки ответа с <. Проверим на встроенном сервере Python: в одном окне запустите python -m http.server 8001 --bind 127.0.0.1, в другом curl -I http://127.0.0.1:8001/.

HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.3
Date: Sun, 11 Oct 2026 08:18:47 GMT
Content-type: text/html; charset=utf-8
Content-Length: 256

Дата и длина у вас будут другими: длина зависит от того, какие файлы лежат в папке. Content-type с маленькой буквой так пишет этот модуль; регистр в имени заголовка роли не играет. Версия ответа HTTP/1.0 по той же причине, что и в нашем сервере без protocol_version. С -v curl показал свой запрос: GET / HTTP/1.1, Host: 127.0.0.1:8001, User-Agent: curl/8.13.0 и Accept: */*.

Из кода на Python

Конец файла смотрит, что отправляет urllib, и пробует подсунуть в значение заголовка перевод строки:

# часть 5: что добавляет urllib и чего не пропустит http.client
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/echo") as answer:
    print(answer.read().decode("utf-8"))

try:
    conn.request("GET", "/echo", headers={"X-User": "anna\r\nX-Admin: 1"})
except ValueError as error:
    print("ValueError:", error)

conn.close()
server.shutdown()
Accept-Encoding: identity
Host: 127.0.0.1:8000
User-Agent: Python-urllib/3.14
Connection: close
x-request-id = None
ValueError: Invalid header value b'anna\r\nX-Admin: 1'

urllib без спроса добавил User-Agent: Python-urllib/3.14 и Connection: close. Серверы часто используют User-Agent, чтобы искать проблемы совместимости, подстраивать ответ под ограничения клиента и собирать статистику. В запросах к чужому API его лучше задать явно. X-Request-Id в этом запросе нет, и сервер получил None.

Вторая попытка не ушла в сеть вовсе: http.client отказался отправить значение с \r\n и бросил ValueError. Если бы такое значение прошло, сервер увидел бы лишний заголовок X-Admin: 1. Это внедрение заголовков (header injection). Данные пользователя вставляйте в заголовок только после проверки и не надейтесь на библиотеку.

Типичные ошибки с заголовками

  • Content-Length посчитан по числу символов, и ответ обрезается на кириллице.
  • Один и тот же заголовок отдают и приложение, и веб-сервер перед ним. Две строки Access-Control-Allow-Origin в одном ответе браузер не принимает, и запрос падает с ошибкой CORS.
  • Сервер выбирает ответ по Accept-Encoding или Origin, но не ставит Vary, и прокси может отдать одному клиенту копию, сжатую или разрешённую для другого.

Две одинаковые бумажные бирки на одной коробке, одна наклеена поверх другой

Частые вопросы об HTTP-заголовках

Важен ли регистр в именах HTTP-заголовков?

Нет: Content-Type и content-type означают одно и то же. Со значениями сложнее, правило своё у каждого заголовка: схема Bearer в Authorization регистр не различает, а сам токен наш сервер сравнивает через ==, посимвольно. Секреты в рабочем коде лучше сравнивать через hmac.compare_digest(): это снижает уязвимость к атакам по времени.

Какие заголовки обязательны?

В запросе HTTP/1.1 обязателен Host, а у запроса с телом нужен Content-Length или Transfer-Encoding: без них тело считается пустым. В ответе Date обязан ставить сервер с часами при кодах 2xx, 3xx и 4xx, а Content-Type следует ставить у сообщения с телом, если тип известен. Заголовки к отдельным кодам собраны в таблице выше.

Чем заголовки отличаются от параметров запроса?

Параметры запроса (query string) стоят в адресе после ?, например /search?q=http, и могут попасть в историю браузера и журналы веб-сервера. Заголовки передаются отдельно от адреса. Поэтому токены и пароли отправляют в заголовке Authorization, а не в параметрах.

Сколько заголовков может быть в запросе?

Стандарт не задаёт ни количества, ни длины отдельного заголовка, ни общего размера. Лимит ставит конкретный сервер или прокси, и на слишком большие заголовки он обязан ответить кодом из группы 4xx. В размер входят и куки: браузер отправляет их в заголовке Cookie.

Как добавить свой заголовок в ответ сервера?

В http.server вызовите self.send_header("Имя", "значение") между send_response() и end_headers(), как в начале примера. В nginx то же делает директива add_header, но без параметра always она добавляет заголовок только к ответам с кодами 200, 201, 204, 206, 301, 302, 303, 304, 307 и 308: на 404 или 500 его не будет. Как проверять заголовки входящих уведомлений от чужого сервиса, разобрано в статье что такое webhook.

Оцените статью
bestprogrammer.ru
Добавить комментарий