Давеча был свидетелем одного интересного спора о том как же действительно нужно определять IP адрес конечного пользователя из скриптов PHP.
Собственно, каждое слово сабжа отображает действительную ситуацию. Это был религиозный спор, обострённый весенней замечательной погодой, в котором, я считаю, не оказалось правых и не правых, но который побудил меня к мини-исследованию и, к моему счастью, поставил точку в понимании этого конфессионального но по факту очень простого вопроса.
Для тех, кто как и я сомневался был уверен, что во всём разобрался, но боялся спросить лень было разбираться в мелочах — под кат.
Предыстория
Занимаясь разработкой VOD сервиса для Samsung SmartTV платформы нам непременно нужно знать страну пользователя, чтобы вдруг нечаянно не показать счастливому пользователю фильм там, где запрещает правообладатель… А ведь за нарушение данного условия договора идут не детские штрафы в тысячах долларов (при чем за каждый факт такой оплошности).
[Вопрос, как заметили в комментариях, Юридический, и мошенничество возможно, но статья даже не о том как постараться предотвратить такие мошенничества, а о том как правильно подружить php и nginx]
На сервере имеем следующее: php-fpm+nginx
Как определить страну? Ну естественно через IP пользователя и GEO IP базу maxmind
«Пффф….» — подумалось нам всем мне — да проще простого. И дабы не писать свой велосипед, нагуглил на stackoverflow, даже вник в каждую строчку, прикрутил и оставил как там и росло код:
public function getUserHostAddress(){
if (!empty($_SERVER['HTTP_X_REAL_IP'])) //check ip from share internet
{
$ip=$_SERVER['HTTP_X_REAL_IP'];
}
elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) //check ip from share internet
{
$ip=$_SERVER['HTTP_CLIENT_IP'];
}
elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) //to check ip is pass from proxy
{
$ip=$_SERVER['HTTP_X_FORWARDED_FOR'];
}
else
{
$ip=$_SERVER['REMOTE_ADDR'];
}
return $ip;
}
И всё работало! Почти год… пока не случилось кое-что неожиданное. Естественно неожиданное для этого кода…
Как запутать php или цепочка прокси(всё ещё часть предыстории)
Всё сломалось! А случилось это когда нам пришлось прикручивать одну из платёжных систем и весь этот код рухнул от того, что в HTTP_X_FORWARDED_FOR пришёл не один адрес, а список адресов через запятую (что строго говоря законно, допустимо, и даже не регламентировано в доке по php)
И никто бы ничего не заметил, если бы HTTP_X_REAL_IP или HTTP_CLIENT_IP(которые тоже не регламентирован докой) содержали искомый IP, но увы они были пусты ?
«Ну ладно» — подумали мы(теперь я был уже не один) перепишем всё и попросим админов запихивать пользовательский IP в переменную REMOTE_ADDR:
public function getUserHostAddress(){
$ip=$_SERVER['REMOTE_ADDR'];
return $ip;
}
И всё работало! Почти месяц… пока не случилось кое-что неожиданное. Естественно неожиданное для этого кода…
Весенний спор крутых мужиков(это не ирония — они крутые)
Всё сломалось! А случилось это потому, что нам нужно было обновить nginx. И мы обратились к профессионалам в этом деле — к нашим админам.
А те в свою очередь решили обновить и конфиг избавившись от нашего «костыля/не костыля» (пока мы этого не поняли) с пробросом в REMOTE_ADDR.
REMOTE_ADDR оставили без изменения т.е. там теперь светилось что-то типа «127.0.0.1»
в HTTP_X_FORWARDED_FOR прокинули IP пользователя (который между делом с лёгкостью удалось переопределить отправкой из браузера заголовка `x-forwarded-for: 999.999.999.999`)
И тут понеслось — Р=Разраб, А=Админ:
А: у вас всё сломалось, и поскольку мы имеем nginx-прокси то нужный вам адрес лежит в HTTP_X_FORWARDED_FOR а в REMOTE_ADDR будет лежать реальный IP сдресс клиента к php-fpm (т.е. 127.0.0.1)
Р: но мы не можем верить HTTP_X_FORWARDED_FOR, ведь это переменная, которую с лёгкостью можно переопределить через заголовок к серверу, ссылаясь на давольно интересную статью
А: нет, мы сделаем так что в ней будет лежать реальный IP конечного пользователя, а в REMOTE_ADDR реальный адрес клиента к php
Р: тогда мы не проследим последовательность проксей, и всё равно для универсализации на другом сервере (скажем без прокси) эти конфиги могут быть не правдивыми пихайте всё в REMOTE_ADDR который в любом случае будет работать.
… это кратко и без матов…
По итогу то конечно всё завелось… и остановились на прозрачном проксировании, когда php думает, что к нему подключаются напрямую клиенты безо всяких проксей и все переменные(точнее одна на которую мы обращаем внимание) в нужном нам состоянии.
Однако не хватает фэншуя в этом деле и по факту у нас ведь есть прокся а может и не одна.
Кто виноват из них кто прав
Судить не нам, но никто!
Если мы имеем действительно кучу клиентов напрямую к php, или прозрачное проксирование то всё просто — юзай REMOTE_ADDR на здоровье и наслаждайся.
Но как быть с фэншуем и где что должно лежать, если мы используем нормальное проксирование и хотим чтобы об этом знал PHP?
Рецепт… но не панацея:
- REMOTE_ADDR — содержит IP адрес непосредственно обращающегося к нему nginx, в нашем случае 127.0.0.1
- HTTP_X_FORWARDED_FOR — содержит цепочку прокси адресов и последним идёт IP непосредственного клиента обратившегося к прокси серверу. И тут рассмотрим два частных случая:
- Не каскадное проксирование. В HTTP_X_FORWARDED_FOR последним или единственным IP адресом (в зависимости от того что прислал/не прислал пользователь в заголовке x-forwarded-for) будет реальный, искомый, тот самый адрес пользователя.
Казалось бы ну в чем проблема парсить эту переменную и доставать оттуда последнийэлемент. Но в нашем случае настройки не были до конца корректными и весьHTTP_X_FORWARDED_FOR заменялся заголовком от браузера x-forwarded-for, а должен был приклеивать к нему реальный IP непосредственного пользователя.
Для примера проверил на промышленном vps хостинге:
Доверять таким данным тоже страшновато, но если всё правильно сделано в настройках то последним IP будет адресс пользователя, вне зависимости от того что придёт в заголовках. - Каскадное проксирование. В этом случае действительно HTTP_X_FORWARDED_FOR — содержит цепочку прокси адресов и последним идёт IP непосредственного клиента обратившегося к прокси серверу. Но это не реальный IP пользователя, а всего лишь IP предыдущей прокси в списке.
Казалось бы ну в чем проблема парсить эту переменную и доставать оттуда первый элемент. Но как было показано выше на рисунке, это уж точно не корректные данные и пользователь может нас ввести в заблуждение в два счёта, прислав в x-forwarded-for первым элементом какой захочет IP
- Не каскадное проксирование. В HTTP_X_FORWARDED_FOR последним или единственным IP адресом (в зависимости от того что прислал/не прислал пользователь в заголовке x-forwarded-for) будет реальный, искомый, тот самый адрес пользователя.
- HTTP_X_REAL_IP (или любая другая переменная на которую договорятся Админ и Разраб) — содержит IP обращающегося к php пользователя или первой от сервера недоверенной прокси (что для нас равно адресу клиента)
Для удобства можно использовать специальный модуль для nginx который нивелирует проблемы определения каскадного и не каскадного проксирования, но он по умолчанию «в стандартных сборках центоса, дебика и федоры nginx идет, почему-то без параметра —with-http_realip_module»(с)Админ, а так же для него должен быть корректно сформированна цепочка в HTTP_X_FORWARDED_FOR и настроены адреса доверенных прокси серверов от которых мы можем брать последний элемент из HTTP_X_FORWARDED_FOR
Однако опять же HTTP_X_REAL_IP это не реальный в общем случае IP конечного пользователя, а лишь первый IP в списке проксей при каскадном проксировании.
Хотя если проксирование не каскадное, то там может лежать и адрес конечного пользователя.
А если проксирование каскадное и корректно настроен модуль http_realip то там должен лежать либо IP конечного пользователя либо корректный IP первой недоверенной прокси если считать от php-сервера, что для нас тоже сгодится - HTTP_CLIENT_IP (или любая другая переменная на которую договорятся Админ и Разраб) — содержит при любом типе проксирования первый IP из HTTP_X_FORWARDED_FOR, а при отсутствии проксирования содержимое http заголовка client-ip. Который можно использовать только для справки. И ни в коем случае не для определения реального IP пользователя.
В заключении
Есть несколько вариантов проксирования для php+nginx
- Прозрачное — характерно неизменное содержание переменных в _SERVER (в том числе и REMOTE_ADDR) как если бы мы работали напрямую с php
- Не прозрачное не каскадное — характерно то, что Админу и Разрабу нужно договориться, где будет храниться реальные IP адрес пользователя ?
- Не прозрачное каскадное — характерно то же самое что и для не прозрачного не каскадного + правильно настроенный модуль для nginx А также необходимо помнить о возможности каскадного проксирования и о том, что пользователь злой и может присылать в _SERVER[«HTTP_xxxx»] очень неправдивые данные
PS
Позже мы наведём фэншуй в настройках и избавимся от прозрачного проксирования, а так же напишем универсальную функцию определения IP для обоих случаев проксирования.
PPS
Ради фана кому интересно: если кто-то в комментариях напишет эту функцию и конфиг nginx за нас и мы её будем использовать, то под честное слово, тот получит 100р на телефон.
Но эта функция и конфиг должны быть во истину православными и учитывать всё ? все зацепки есть в статье.
Главное — дзен: не торопитесь — вдруг первые напишут с ошибками и вы их учтёте, торопитесь — вдруг первый правильный ответ будет до Вас.
UDP:
Своя реализация:
/**
* @param null|string $ip_param_name - ключ элемента _SERVER, в котором нужно искать IP адрес
* если не задано ищем по индексу REMOTE_ADDR и считаем что проксирование отсутствует или прозрачное,
* если задано считаем что IP пробрасывается по заданному индексу,
* например по индексу HTTP_X_REAL_IP или любому другому
* @param bool $allow_non_trusted - защита, при заданном $ip_param_name но
* отсутствующем или не валидном значении _SERVER[$ip_param_name]
* если задано будем искать в _SERVER по ключам из аргумента $non_trusted_param_names
* @param array $non_trusted_param_names - массив ключей, по которым будем искать IP в массиве _SERVER
* @throws Exception
* @return string
*/
public function getUserHostAddress(
$ip_param_name = null,
$allow_non_trusted = false,
array $non_trusted_param_names = array('HTTP_X_REAL_IP','HTTP_CLIENT_IP','HTTP_X_FORWARDED_FOR','REMOTE_ADDR')
){
if(empty($ip_param_name) || !is_string($ip_param_name)){
// если не задан или не корректен
$ip = $_SERVER['REMOTE_ADDR'];
}else{
//иначе используем нужную переменную
if(!empty($_SERVER[$ip_param_name]) && filter_var($_SERVER[$ip_param_name], FILTER_VALIDATE_IP)){
// если переменная подошла как надо
$ip = $_SERVER[$ip_param_name];
}else if($allow_non_trusted){
// мы решили пойти на крайний шаг и использовать сырые данные
foreach($non_trusted_param_names as $ip_param_name_nt){
if($ip_param_name === $ip_param_name_nt)
// мы уже проверяли эту переменную
continue;
if(!empty($_SERVER[$ip_param_name_nt]) && filter_var($_SERVER[$ip_param_name_nt], FILTER_VALIDATE_IP)){
// если переменная подошла как надо
$ip = $_SERVER[$ip_param_name_nt];
break;
}
}
}
}
if(empty($ip))
// так и не нашли подходящих ip, хотя по умолчанию в $_SERVER['REMOTE_ADDR'] что-то должно лежать
throw new Exception("Can't detect IP");
return $ip;
}