0% прочитано

Nginx і довірені X-* заголовки: коли Laravel може їм довіряти, а коли клієнт може їх підробити

HTTP-заголовок не стає довіреним лише через назву X-*. У ICanUp це стало важливо під час передавання країни відвідувача з Nginx у Laravel: клієнт може надіслати такий самий заголовок самостійно. Розбираю, де повинна проходити межа довіри, чому Nginx має перезаписувати значення і коли Laravel справді може використовувати внутрішній заголовок як надійне джерело.

16 вересня 2026 р. 11 хв читанняNginx

Коли Laravel читає request()->header('X-Icanup-Country-Code'), він бачить лише значення HTTP-заголовка. Сам Laravel не знає, хто його створив: Nginx, CDN, корпоративний проксі чи звичайний браузер користувача.

У ICanUp це стало важливим після того, як я перенесла визначення країни відвідувача на рівень Nginx і MaxMind.

Я хотіла передавати в застосунок лише дволітерний код країни, наприклад UA, і не зберігати IP-адресу в Analytics.

Але одразу виникло інше питання: що завадить клієнту самому надіслати такий заголовок?

Назва X-* не робить HTTP-заголовок внутрішнім або безпечним. За замовчуванням заголовок запиту є даними від клієнта, доки інфраструктура не створила окрему довірену межу.
Client sends a forged X-Icanup-Country-Code header, Nginx overwrites it with a GeoIP value, and Laravel receives only the trusted internal value
Клієнт може надіслати власний X-* заголовок. Довіреним він стає лише тоді, коли Nginx не пропускає клієнтське значення напряму, а формує або перезаписує його сам.

HTTP-заголовок стає питанням безпеки в момент, коли Laravel починає йому довіряти

Більшість HTTP-заголовків є просто вхідними даними.

Проблема починається тоді, коли застосунок використовує їх для рішення, яке має наслідки: визначення IP, країни, HTTPS-схеми, домену, доступу, журналювання або аналітики.

У цей момент потрібно відповісти не на питання "чи є заголовок?", а на питання "хто гарантовано встановив це значення?".

Джерело значення

Чи можна довіряти автоматично

Чому

Звичайний HTTP-запит від клієнта

Ні

Клієнт контролює власні заголовки

Nginx, який сам обчислив значення

Так, якщо клієнтське значення не передається напряму

Nginx є контрольованою межею перед PHP

CDN або зовнішній проксі

Не завжди

Потрібно гарантувати, що origin приймає значення тільки від довіреного проксі

Заголовок із красивою назвою X-Internal-*

Ні

Назва не створює захисту

У моєму випадку заголовок передає тільки код країни

Для Public Analytics мені була потрібна країна відвідувача, але я не хотіла зберігати його IP-адресу або виконувати зовнішній GeoIP HTTP-запит при кожному перегляді.

Тому Nginx визначає країну локально через MaxMind, а Laravel отримує лише нормалізований дволітерний код.

Сам підхід я детально описала в окремій публікації про GeoIP Analytics через Nginx, MaxMind і довірений заголовок.

На рівні Laravel назва заголовка задається через середовище, а не записується жорстко в коді.

ENV - Назва внутрішнього заголовка для Analytics
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-Code

Небезпечне припущення: клієнт не знає про мій внутрішній заголовок

На це не можна покладатися.

Навіть якщо заголовок не документований публічно, користувач може побачити його в коді, конфігурації, статті, журналі або просто вгадати назву.

Будь-який HTTP-клієнт може спробувати надіслати його самостійно.

BASH - Клієнт сам надсилає внутрішній заголовок
curl \  -H 'X-Icanup-Country-Code: US' \  https://icanup.com.ua/

Якщо після такого запиту Laravel бачить US лише тому, що клієнт його передав, заголовок не є довіреним.

Це не проблема Laravel. Laravel отримав саме те, що йому передала інфраструктура.

Межа довіри в моїй схемі проходить через Nginx

У моєму випадку Nginx стоїть перед PHP-FPM. Саме тому він є правильним місцем, де можна відокремити клієнтські дані від внутрішніх.

Ключове правило: Laravel повинен отримати значення, сформоване Nginx, а не значення з однойменного клієнтського заголовка.

Nginx - Не передавати клієнтське значення як довірене
# Небезпечний варіант:# значення фактично береться від клієнта. fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE \    $http_x_icanup_country_code;
Змінна $http_x_icanup_country_code представляє значення, яке прийшло у HTTP-запиті. Якщо використати її як внутрішнє джерело істини, клієнт зможе керувати значенням сам.
Nginx - Передавати в PHP значення, визначене Nginx
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {    auto_reload 1h;    $geoip2_country_code country iso_code;} location ~ \.php$ {    include fastcgi_params;     fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE \        $geoip2_country_code;     fastcgi_pass unix:/run/php/php-fpm.sock;}

Шлях до GeoIP-бази та PHP-FPM socket тут наведені як приклад. Важливий не конкретний шлях, а напрямок даних.

У безпечній схемі значення HTTP_X_ICANUP_COUNTRY_CODE формується з результату GeoIP у Nginx. Клієнтський X-Icanup-Country-Code не стає джерелом істини.

Перезаписати заголовок важливіше, ніж просто додати його

Якщо клієнт уже надіслав заголовок із такою самою назвою, інфраструктура не повинна залишати неоднозначність між двома значеннями.

На межі Nginx я хочу отримати один детермінований результат.

Клієнт надіслав

Nginx визначив

Що має отримати Laravel

нічого

UA

UA

US

UA

UA

DE

PL

PL

довільний текст

невідома країна

порожнє або безпечне невідоме значення

Той самий принцип стосується X-Forwarded-For

Найвідоміший приклад тієї самої проблеми - X-Forwarded-For.

Laravel часто використовує проксі-заголовки для визначення реальної IP-адреси клієнта, HTTPS-схеми або host.

Але якщо застосунок сліпо довіряє X-Forwarded-For від будь-якого джерела, клієнт може надіслати власне значення.

Тому довіра до forwarded headers завжди повинна бути пов’язана з довірою до конкретного проксі, який їх сформував.

Trusted proxy означає довірений вузол, а не довірену назву заголовка

Налаштування trusted proxies у Laravel має сенс лише тоді, коли відомо, через які саме проксі проходить запит.

Логіка проста:

якщо запит прийшов через відомий довірений проксі, Laravel може врахувати створені ним forwarding headers. Якщо клієнт може напряму обійти цей проксі й звернутися до origin, межа довіри вже інша.

Це особливо важливо для CDN. Заголовок від CDN можна вважати довіреним лише тоді, коли origin налаштований так, щоб клієнт не міг обійти CDN і напряму надіслати той самий заголовок.

Прямий доступ до origin може зламати правильну на вигляд схему

Уявімо, що CDN додає X-Country-Code, а Laravel довіряє йому.

Якщо production-сервер одночасно доступний напряму з Інтернету, зловмисник може обійти CDN і надіслати на origin:

BASH - Обхід довіреного CDN через прямий доступ до origin
curl \  -H 'X-Country-Code: US' \  https://ORIGIN_IP/

Тому безпека такого заголовка залежить не тільки від Laravel config. Вона залежить від мережевої схеми, firewall, Nginx і того, хто реально може звернутися до origin.

Laravel у моїй схемі не визначає країну самостійно

Після того як довіра встановлена на рівні Nginx, Laravel має значно простішу задачу.

Він не аналізує IP і не намагається повторно виконувати GeoIP lookup. Він лише читає внутрішній заголовок, нормалізує значення і відкидає все, що не відповідає очікуваному формату.

PHP - Скорочений контракт нормалізації коду країни
$header = config('analytics.country_header'); if (! is_string($header) || $header === '') {    return null;} $countryCode = strtoupper(    trim((string) $request->header($header))); return preg_match('/^[A-Z]{2}$/', $countryCode) === 1    ? $countryCode    : null;

У реальному застосунку цей обов’язок виконує окремий resolver аналітики. Тут важлива сама межа відповідальності: Nginx визначає довірене значення, Laravel перевіряє його формат.

Відсутнє або неправильне значення не повинно ламати сторінку

GeoIP не є критичною функцією для показу публічної сторінки.

Якщо код країни відсутній, база MaxMind тимчасово недоступна або значення не відповідає формату, я не хочу повертати користувачу 500.

У такому випадку Analytics просто не отримує country dimension для цього перегляду.

Як я перевіряю, що підміна не працює

Я перевіряю не тільки нормальний запит, а й спробу навмисної підміни.

Для цього достатньо контрольованого dev-середовища або тимчасової діагностики, яка показує ефективне значення заголовка після Nginx.

  • Зробити звичайний запит і зафіксувати значення, яке визначив Nginx.
  • Повторити запит із власним X-Icanup-Country-Code.
  • Переконатися, що клієнтське значення не потрапило в Laravel.
  • Перевірити неправильне значення, наприклад INVALID.
  • Перевірити відсутність GeoIP-результату.
  • Переконатися, що публічна сторінка працює навіть без country code.
  • Перевірити, що Analytics зберігає лише нормалізований дволітерний код.
  • Переконатися, що IP-адреса не потрапляє в історію Analytics.
BASH - Контрольна спроба підміни заголовка
curl \  -H 'X-Icanup-Country-Code: US' \  https://dev.icanup.com.ua/

Я не створюю для цього постійний публічний маршрут, який показує внутрішні заголовки. Діагностика має бути тимчасовою або доступною лише в контрольованому середовищі.

Що я не роблю з довіреними заголовками

  • Не вважаю заголовок безпечним лише через префікс X-.
  • Не довіряю значенню тільки тому, що його назва містить Internal або Trusted.
  • Не передаю $http_x_... у Laravel як внутрішнє джерело істини.
  • Не довіряю forwarded headers без визначеної межі trusted proxy.
  • Не вважаю CDN-заголовок надійним, якщо origin можна обійти напряму.
  • Не використовую країну з заголовка для критичного authorization-рішення.
  • Не зберігаю IP-адресу в Analytics лише заради визначення країни.
  • Не роблю зовнішній GeoIP HTTP-запит при кожному перегляді.
  • Не ламаю public rendering, якщо країну визначити не вдалося.

Мій короткий checklist для внутрішнього X-* заголовка

  • Чітко визначити, який компонент має право створювати заголовок.
  • Не використовувати клієнтське значення як внутрішнє.
  • На межі Nginx явно формувати або перезаписувати значення.
  • Перевірити можливість прямого доступу до origin.
  • Для зовнішнього проксі обмежити довіру конкретними вузлами або мережами.
  • У Laravel перевіряти формат навіть довіреного значення.
  • Для необов’язкових даних мати безпечну поведінку при їх відсутності.
  • Окремо перевірити навмисну підміну через curl.
  • Не використовувати такий заголовок для більш критичної задачі, ніж дозволяє його модель довіри.

Довіреним є не заголовок. Довіреним є шлях, яким його значення потрапило до застосунку.

Що я залишила собі на майбутнє

  • Будь-який HTTP-заголовок спочатку вважати клієнтськими даними.
  • Довіру встановлювати на інфраструктурній межі, а не за назвою заголовка.
  • Внутрішні значення формувати або перезаписувати перед передаванням у PHP.
  • Розглядати Nginx, CDN і Laravel як окремі рівні з різною відповідальністю.
  • Завжди перевіряти сценарій прямого обходу проксі.
  • Нормалізувати значення в Laravel навіть після довіреного Nginx.
  • Перевіряти не тільки правильний запит, а й навмисну спробу підміни.
  • Не змішувати аналітичні дані з authorization-рішеннями без окремої моделі безпеки.

Висновок

Після додавання GeoIP у ICanUp я могла просто навчити Laravel читати X-Icanup-Country-Code і на цьому зупинитися.

Але тоді вся модель довіри залежала б від припущення, що клієнт ніколи не надішле такий самий заголовок.

Безпечніша схема інша: Nginx сам визначає країну, формує значення, яке потрапляє у PHP, а Laravel лише нормалізує його та використовує як необов’язкову аналітичну ознаку.

Той самий принцип стосується X-Forwarded-For, CDN-заголовків та інших внутрішніх X-* значень.

Laravel може довіряти заголовку лише настільки, наскільки він може довіряти інфраструктурному шляху, який цей заголовок сформував.