Коли Laravel читає request()->header('X-Icanup-Country-Code'), він бачить лише значення HTTP-заголовка. Сам Laravel не знає, хто його створив: Nginx, CDN, корпоративний проксі чи звичайний браузер користувача.
У ICanUp це стало важливим після того, як я перенесла визначення країни відвідувача на рівень Nginx і MaxMind.
Я хотіла передавати в застосунок лише дволітерний код країни, наприклад UA, і не зберігати IP-адресу в Analytics.
Але одразу виникло інше питання: що завадить клієнту самому надіслати такий заголовок?

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 назва заголовка задається через середовище, а не записується жорстко в коді.
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-CodeНебезпечне припущення: клієнт не знає про мій внутрішній заголовок
На це не можна покладатися.
Навіть якщо заголовок не документований публічно, користувач може побачити його в коді, конфігурації, статті, журналі або просто вгадати назву.
Будь-який HTTP-клієнт може спробувати надіслати його самостійно.
curl \ -H 'X-Icanup-Country-Code: US' \ https://icanup.com.ua/Якщо після такого запиту Laravel бачить US лише тому, що клієнт його передав, заголовок не є довіреним.
Це не проблема Laravel. Laravel отримав саме те, що йому передала інфраструктура.
Межа довіри в моїй схемі проходить через Nginx
У моєму випадку Nginx стоїть перед PHP-FPM. Саме тому він є правильним місцем, де можна відокремити клієнтські дані від внутрішніх.
Ключове правило: Laravel повинен отримати значення, сформоване Nginx, а не значення з однойменного клієнтського заголовка.
# Небезпечний варіант:# значення фактично береться від клієнта. fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE \ $http_x_icanup_country_code;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, межа довіри вже інша.
Прямий доступ до origin може зламати правильну на вигляд схему
Уявімо, що CDN додає X-Country-Code, а Laravel довіряє йому.
Якщо production-сервер одночасно доступний напряму з Інтернету, зловмисник може обійти CDN і надіслати на origin:
curl \ -H 'X-Country-Code: US' \ https://ORIGIN_IP/Тому безпека такого заголовка залежить не тільки від Laravel config. Вона залежить від мережевої схеми, firewall, Nginx і того, хто реально може звернутися до origin.
Laravel у моїй схемі не визначає країну самостійно
Після того як довіра встановлена на рівні Nginx, Laravel має значно простішу задачу.
Він не аналізує IP і не намагається повторно виконувати GeoIP lookup. Він лише читає внутрішній заголовок, нормалізує значення і відкидає все, що не відповідає очікуваному формату.
$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.
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 може довіряти заголовку лише настільки, наскільки він може довіряти інфраструктурному шляху, який цей заголовок сформував.



