Самое страшное зло в Nginx - это if в location. Об этом написано много, в том числе на nginx.com. Процитируем кусочек:

The only 100% safe things which may be done inside if in a location context are:

- return ...;

- rewrite ... last;

Казалось бы, если использовать конструкцию вида

location / {
  if ( $condition ) {
    return 418;
  }
  ...
}

то ничего страшного не произойдет, однако, при определенном "умении", можно сломать даже то, что должно работать на 100%. Но будет ли виноват в нашей поломке if?

Предыдущие статьи

Предположим, что у нас был веб-сервер принимающий POST-запросы:

server {
    root /www/example_com;
    listen *:80;
    server_name  .example.com;
        
    location /index.php {
        fastcgi_pass   unix:/var/run/php-fpm.sock;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}
$ cat /www/example_com/index.php
<?php
var_dump($_REQUEST);
$ curl \
> -w "HTTP CODE: %{http_code}\n" \
> -d "key1=value1" \
> -X POST \
> "example.com/index.php" \
> 
array(1) {
  ["key1"]=>
  string(6) "value1"
}
HTTP CODE: 200

И в какой-то момент мы захотели отфильтровать запросы с пустыми телом (навеяно вопросом на форуме). Первый порыв мысли приводит конфиг к такому виду:

server {
    root /www/example_com;
    listen *:80;
    server_name  .example.com;
        
    location /index.php {
        fastcgi_pass   unix:/var/run/php-fpm.sock;
        fastcgi_param  SCRIPT_FILENAME         $document_root$fastcgi_script_name;
        include        fastcgi_params;
    
        if ( $request_body = '' ) {
          return 418;
        }
    }
}

Однако, мысль эта ошибочна, и в такой конфигурации мы всегда (как с отправленным телом так и без) получим 418-й ответ:

$ curl \
> -w "HTTP CODE: %{http_code}\n" \
> -X POST \
> "example.com/index.php" \
> 
HTTP CODE: 418
$ curl \
> -w "HTTP CODE: %{http_code}\n" \
> -d "key1=value1" \
> -X POST \
> "example.com/index.php" \
> 
HTTP CODE: 418
Как определены фазы в Nginx
typedef enum {
    NGX_HTTP_POST_READ_PHASE = 0,
    NGX_HTTP_SERVER_REWRITE_PHASE,
    NGX_HTTP_FIND_CONFIG_PHASE,
    NGX_HTTP_REWRITE_PHASE,
    NGX_HTTP_POST_REWRITE_PHASE,
    NGX_HTTP_PREACCESS_PHASE,
    NGX_HTTP_ACCESS_PHASE,
    NGX_HTTP_POST_ACCESS_PHASE,
    NGX_HTTP_PRECONTENT_PHASE,
    NGX_HTTP_CONTENT_PHASE,
    NGX_HTTP_LOG_PHASE
} ngx_http_phases;

В нашем условии используется переменная $request_body, значение которой

появляется в location’ах, обрабатываемых директивами proxy_passfastcgi_passuwsgi_pass и scgi_pass, когда тело было прочитано в буфер в памяти.

Следовательно, значение переменной устанавливается в фазе NGX_HTTP_CONTENT_PHASE, в то время как, директива if модуля ngx_http_rewrite_module исполняется в фазе NGX_HTTP_REWRITE_PHASE, то есть, на момент проверки условия, переменной $request_body не будет присвоено никакого значения вне зависимости от того пуст body или нет, поэтому при любых запросах ответом будет "418 I'm a teapot".

Таким образом, хоть мы и поломали гарантированно работающий вариант, if в location здесь совершенно не причем. Всему "виной" лишь порядок фаз обработки запроса.

Комментарии (25)


  1. mSnus
    03.08.2021 11:34

    Допишите сразу про map вместо if, удобнее будет


    1. simpleadmin Автор
      03.08.2021 11:37

      По map есть отличная статья, но а данном случае он также не поможет.


  1. aamonster
    03.08.2021 12:27

    А правильное решение есть? Как-то без него статья не смотрится.
    Убедить nginx, что у нас тут fastcgi/proxy, как делают в https://stackoverflow.com/questions/4939382/logging-post-data-from-request-body? Как-то не очень...


    1. simpleadmin Автор
      03.08.2021 12:42

      Прототип решения по ссылке на форум вполне хорош.
      Нам нужно переместить обработку условия в CONTENT-фазу, njs вполне для этого подходит, применительно к примеру из статьи как-то так:

      js_import check_body.js;
      server {
          root /www/example_com;
          listen *:80;
          server_name  .example.com;
              
          location /index.php {
              fastcgi_pass   unix:/var/run/php-fpm.sock;
              fastcgi_param  SCRIPT_FILENAME         $document_root$fastcgi_script_name;
              include        fastcgi_params;
              
              js_content check_body.body_empty;
          }
      }
      function body_empty(r) {
        if (r.method == "POST" && (typeof(r.requestBody) == "undefined" || r.requestBody === null)) {
          r.return(418, "Empty\n");
        } else {
          r.return(200, "ret:[" + r.requestBody + "]\n");
        }
      }
      
      export default {body_empty};
      $ curl -w "HTTP CODE: %{http_code}\n" -d "key1=value1" -X POST "example.com/index.php" \
      ret:[key1=value1]
      HTTP CODE: 200
      $ curl -w "HTTP CODE: %{http_code}\n" -X POST "example.com/index.php"
      Empty
      HTTP CODE: 418


      1. aamonster
        03.08.2021 13:02
        +1

        А использовать ноду точно будет дешевле, чем прямо в fastcgi-скрипте проверить?


        1. simpleadmin Автор
          03.08.2021 13:37

          Скажем так - я не думаю, что njs станет узким местом.
          Но и делать так я точно не стал бы - сопровождать такое никакого желания. Решение только на случай если "можно использовать только nginx".
          Как вариант можно использовать https://github.com/calio/form-input-nginx-module:

              location /index.php {
                  fastcgi_pass   unix:/var/run/php-fpm.sock;
                  fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                  include fastcgi_params;
                  
                  set_form_input_multi $data;
                  
                  if ( $data = '' ) {
                    return 418;
                  }
              }
          

          Но, опять же, спорное решение и в плане поддержки и в плане производительности.


        1. BasilioCat
          03.08.2021 13:38

          Это не нода, а встроенный в nginx JS-движок, по скорости не должен уступать lua в оpenresty. Однако, если есть строгая необходимость логгировать $request_body без лишних скриптов, то можно его проксировать на себя, в отдельный сервер/порт на 127.0.0.1, в котором уже можно делать return и прочие запретные вещи.


          1. aamonster
            03.08.2021 17:57

            Спасибо.

            Давайте рассмотрим два крайних случая:

            1. Один реквест из тысячи с пустым body.

            2. Один реквест из тысячи с непустым body.

            Собственно, мне просто интересно для каждого из двух случаев – что обойдётся дешевле: "if" вынесенный в отдельную njs-функцию (т.е. в обоих случаях 1000 раз выполнится js) или "if" в начале обработки FastCGI-запроса (сразу после Accept или что там)? Или это зависит от того, на чём FastCGI сделан?


            1. aamonster
              03.08.2021 18:00

              Ну и третий вариант, как написал @RekGRpth– использовать echo_read_request_body. Насколько это дорогое удовольствие?


              1. simpleadmin Автор
                04.08.2021 08:21

                Использование echo_read_request_body - абсолютно бесполезно, точнее просто вредно. Если пакет поднялся до фазы CONTENT, то для того чтобы выполнить if (значит попасть в фазу REWRITE) у него есть только один выход - выполнить проксирование. Но при использовании директивы proxy_pass переменная $request_body получит значение автоматически, то есть echo_read_request_body просто лишено смысла.


            1. simpleadmin Автор
              03.08.2021 20:27
              +1

              server {
                  root /www/example_com;
                  listen *:80;
                  server_name  .example.com;
                      
                  location /index.php {
                      fastcgi_pass   unix:/var/run/php-fpm.sock;
                      fastcgi_param  SCRIPT_FILENAME         $document_root$fastcgi_script_name;
                      include        fastcgi_params;
                  }
              }
              <?php
              if(!count($_REQUEST)) http_response_code(418);
              die();
              

              19691

              js_import /scripts/nginx/js/check_body.js;
              server {
                  root /www/example_com;
                  listen *:80;
                  server_name  .example.com;
                      
                  location /index.php {
                      fastcgi_pass   unix:/var/run/php-fpm.sock;
                      fastcgi_param  SCRIPT_FILENAME         $document_root$fastcgi_script_name;
                      include        fastcgi_params;
                      
                      js_content check_body.body_empty;
                      #if ( $request_body = '' ) {
                      #  return 418;
                      #}
                  }
              }

              12263

              server {
                  root /www/example_com;
                  listen *:80;
                  server_name  .example.com;
                      
                  location /index.php {
                      fastcgi_pass   unix:/var/run/php-fpm.sock;
                      fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                      include fastcgi_params;
                      
                      set_form_input_multi $data;
                      
                      if ( $data = '' ) {
                        return 418;
                      }
                  }
              }

              6009

              Время в наносекундах от ngx_http_handler:entry до ngx_http_finalize_request:return

              Усредненное для 100000 "пустых" POST-запросов.


              1. Nnnnoooo
                03.08.2021 20:32

                Спасибо!
                Хороший пример насколько модуль быстрее njs.
                Сейчас под рукой нет опенрести чтобы луа проверить, но сразу после выхода njs, когда тестировал луа был самую малость быстрее. Но то может на моих сценариях.


                1. simpleadmin Автор
                  03.08.2021 22:39

                  Хороший пример насколько модуль быстрее njs.

                  Скорее пример того насколько быстрее будет завершить обработку в REWRITE-фазе, чем в CONTENT

                  Маршруты пакета для обоих случаев:

                  $ cat /tmp/tree.txt | grep phase | awk '{print $3}' | uniq
                  ngx_http_core_generic_phase
                  ngx_http_core_rewrite_phase
                  ngx_http_core_find_config_phase
                  ngx_http_core_rewrite_phase
                  
                  $ cat /tmp/tree_njs.txt | grep phase | awk '{print $3}' | uniq
                  ngx_http_core_generic_phase
                  ngx_http_core_rewrite_phase
                  ngx_http_core_find_config_phase
                  ngx_http_core_rewrite_phase
                  ngx_http_core_post_rewrite_phase
                  ngx_http_core_generic_phase
                  ngx_http_core_access_phase
                  ngx_http_core_post_access_phase
                  ngx_http_core_generic_phase
                  ngx_http_core_content_phase


                  1. Nnnnoooo
                    03.08.2021 23:19

                    Спасибо за уточнение.
                    Надо будет потом потестить все это на реальных данных.
                    Последнее время забил на все эти защиты на стороне nginx-а, все равно помогают только от самого школьного ддоса.
                    А если нужна какая-то хитрая обработка запроса именно на стороне nginx-а, то тогда уже известный openresty или в крайнем случае njs


              1. Nnnnoooo
                03.08.2021 20:36

                А насчет fastcgi это идеальные данные, в реальности под нагрузкой будет еще медленнее, т.к. очень плохо параллелится, под сверх нагрузками.

                Насколько я понял из задачи это была простая защита от спама пустыми POST запросами и тут бы все в php-fpm все уперлось


          1. Nnnnoooo
            03.08.2021 20:29

            Кстати, проксирование на себя позволяет делать и другие костыли, для обмана порядка фаз обработки запроса.
            Но все-таки это вообще не интуитивно и легко в будущем потом сломать если планируются правки конфига. Поэтому раньше было проще опенрести поставить и юзать lua, а сейчас на мейнлайне njs уже можно использовать


    1. RekGRpth
      03.08.2021 16:42
      -2

      я так обычно делаю, если надо обойти if

      location /index.php {
        	if ( $request_body = '' ) {
          		return 418;
        	}
          try_files /nonexistent @proxy;
      }
      location @proxy {
        	fastcgi_pass ...
      } 

      главное, чтобы /nonexistent не существовал


      1. simpleadmin Автор
        03.08.2021 16:55

        Как это поможет установить значение в переменной $request_body?


        1. RekGRpth
          03.08.2021 17:08
          -2

          а вот это уже другой вопрос и на него можно ответить например так echo_read_request_body


          1. simpleadmin Автор
            03.08.2021 20:19

            echo-nginx-module работает в CONTENT-фазе, там мы можем получить $request_body и без сторонних модулей.

            Как всё это поможет проверить переменную $request_body в REWRITE-фазе?


            1. RekGRpth
              04.08.2021 13:55

              хм... точно, а вот так сработало

              location /index.php {
                  set_form_input $data;
                  if ($request_body = '') { return 418; }
                  try_files /nonexistent @proxy;
              }
              location @proxy {
                	fastcgi_pass ...
              } 


              1. simpleadmin Автор
                04.08.2021 14:18

                Этот пример я уже приводил выше.
                Так и должно работать, так как директивы модуля работают в фазе REWRITE:

                $ grep -rnF PHASE ../form-input-nginx-module-master/
                ../form-input-nginx-module-master/src/ngx_http_form_input_module.c:372:    h = ngx_array_push(&cmcf->phases[NGX_HTTP_REWRITE_PHASE].handlers);
                


              1. simpleadmin Автор
                04.08.2021 14:20

                Ну и try_files здесь не нужен, так как работаем в REWRITE_PHASE, то и if отработает нормально.


              1. DDoSInsurance
                04.08.2021 14:50

                Очень плохой пример.

                1) $request_body здесь установился "случайно", просто потому что модуль form-input-nginx-module получил/установил его при парсинге $data.

                2) Лишние телодвижения с try_files. Вы поднялись до PRECONTENT_PHASE и зачем-то пошли на второй круг по всем фазам (заново, начиная от REWRITE_PHASE) вместо того чтобы сразу перейти к CONTENT_PHASE. Этот дополнительный проход не выполняет никаких действий вообще.


  1. myc
    06.08.2021 23:11
    +1

    Я бы проверял не $request_body, а наличие $http_content_length или $http_transfer_encoding = ‘chunked’. Обе эти переменные уже определены на этапе REWRITE_PHASE.