REMOTE_ADDR vs HTTP_X_FORWARDED_FOR

Давеча был свидетелем одного интересного спора о том как же действительно нужно определять 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_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;
    }