Если вы сталкивались с CORS, то знаете всю ту боль, которую испытывает разработчик, когда нужно сходить к API на другом домене. Если конфигурация сервера не доступна для настройки, то использовали какое-нибудь решение на основе не менее популярного решения cors-anywhere.

Пятница вечер делать нечего

Не многим изестно, что директива proxy_pass поддерживает не только локальные домены и потоки (aka upstream), но и внешние источники, например:

proxy_pass https://api.github.com/$request_uri

Так зародилась идея написать универсальный (с некоторыми оговорками) конфиг для nginx, который поддерживает любой переданный домен.

Чем мы можем управлять

Мы можем объявлять новые переменные на основе глобальных c поддержой регулярных выражений с помощью map:

map $request_url $my_request_path {
  ~*/(.*)$ $1;
  default  "";
}

Так, при запросе к http://example.com/api в переменной $my_request_path будет лежать api.

Мы можем отправлять клиенту дополнительные заголовки с помощью add_header:

add_header X-Request-Path $my_request_path always;

Теперь у нас добавился заголовок X-Request-Path с значением api.

С помощью директивы proxy_set_header добавлять заголовки к запросу, который отправляется proxy_pass. А с помощью proxy_hide_header скрывать заголовки, которые мы получили от proxy_pass.

С помощью директивы if обрабатывать выражения, например, при запросе методом OPTIONS отдавать сразу нужный код ответа:

if ($request_method = OPTIONS) {
  return 204;
}

Собираем все вместе

Для начала объявим $proxy_uri который мы будем извлекать из $request_uri:

map $request_uri $proxy_uri {
  ~*/http://(.*)/(.+)$  "http://$1/$2";
  ~*/https://(.*)/(.+)$ "https://$1/$2";
  ~*/http://(.*)$       "http://$1/";
  ~*/https://(.*)$      "https://$1/";
  ~*/(.*)/(.+)$         "https://$1/$2";
  ~*/(.*)$              "https://$1/";
  default               "";
}

Если коротко это работает так: при запросе http://example.com/example.ru, в переменной $proxy_uri будет лежать https://example.ru

Из полученного $proxy_uri извлечем часть, которая будет соответствовать заголовку Origin:

map $proxy_uri $proxy_origin {
  ~*(.*)/.*$ $1;
  default    "";
}

Для заголовка Forwarded нам понадобится обработать сразу 2 переменные:

map $remote_addr $proxy_forwarded_addr {
  ~^[0-9.]+$        "for=$remote_addr";
  ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\"";
  default           "for=unknown";
}

map $http_forwarded $proxy_add_forwarded {
  ""      "$proxy_forwarded_addr";
  default "$http_forwarded, $proxy_forwarded_addr";
}

Обработка заголовока X-Forwarded-For уже встроена в nginx

Теперь мы можем перейти к объявлению нашего проксирующего сервера:

server {
  listen 443 ssl; 
  
  server_name cors.example.com;
  
  proxy_http_version         1.1;
  proxy_pass_request_headers on;
  proxy_pass_request_body    on;
  
  proxy_redirect             off;
  resolver                   77.88.8.8 77.88.8.1 8.8.8.8 8.8.4.4 valid=1d;
  
  location / {
    if ($proxy_uri = "") {
      # empty uri
      return 403;
    }
    
    # add proxy cors headers
    add_header Access-Control-Allow-Headers "*" always;
    add_header Access-Control-Allow-Methods "*" always;
    add_header Access-Control-Allow-Origin  "*" always;

    if ($request_method = OPTIONS) {
      return 204;
    }
    
    proxy_set_header Host                $proxy_host;
    proxy_set_header Origin              $proxy_origin;
    proxy_set_header X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto   $scheme;
    proxy_set_header Forwarded           "$proxy_add_forwarded;proto=$scheme";
    
    proxy_pass $proxy_uri;
  }
}

Мы получили минимально рабочий проксирующий сервер, у которого обрабатывается CORS Preflight Request и добавляются соответствующие заголовки.

Делаем красиво

Все бы хорошо, но если у сервера, к которому мы проксируем, будет настроена обработка CORS, то его заголовки будут передаваться клиенту. Давайте скроем все возможные:

# hide original cors
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Expose-Headers;
proxy_hide_header Access-Control-Max-Age;
proxy_hide_header Access-Control-Request-Headers;
proxy_hide_header Access-Control-Request-Method;

Хорошо бы еще передавать IP клиента, чтобы хоть как-то обходить rate limit, который может возникнуть, если несколько пользователей будут обращаться к одному ресурсу:

proxy_set_header X-Real-IP           $remote_addr;
proxy_set_header X-Client-IP         $remote_addr;
proxy_set_header CF-Connecting-IP    $remote_addr;
proxy_set_header Fastly-Client-IP    $remote_addr;
proxy_set_header True-Client-IP      $remote_addr;
proxy_set_header X-Cluster-Client-IP $remote_addr;

Мы же не говорим про анонимность, верно?)

И, напоследок, немного улучшим производительность выключив кэш/буферизацию/etc:

sendfile                   on;
tcp_nodelay                on;
tcp_nopush                 on;

etag                       off;
if_modified_since          off;

proxy_buffering            off;
proxy_cache                off;
proxy_cache_convert_head   off;
proxy_max_temp_file_size   0;
client_max_body_size       0;

proxy_read_timeout         1m;
proxy_connect_timeout      1m;
reset_timedout_connection  on;

gzip                       off;
gzip_proxied               off;
# brotli                   off;
Конфиг полностью
map $request_uri $proxy_uri {
  ~*/http://(.*)/(.+)$  "http://$1/$2";
  ~*/https://(.*)/(.+)$ "https://$1/$2";
  ~*/http://(.*)$       "http://$1/";
  ~*/https://(.*)$      "https://$1/";
  ~*/(.*)/(.+)$         "https://$1/$2";
  ~*/(.*)$              "https://$1/";
  default               "";
}

map $proxy_uri $proxy_origin {
  ~*(.*)/.*$ $1;
  default    "";
}

map $remote_addr $proxy_forwarded_addr {
  ~^[0-9.]+$        "for=$remote_addr";
  ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\"";
  default           "for=unknown";
}

map $http_forwarded $proxy_add_forwarded {
  ""      "$proxy_forwarded_addr";
  default "$http_forwarded, $proxy_forwarded_addr";
}

server {
  listen 443 ssl;
  
  ssl_certificate /etc/letsencrypt/live/cors.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/cors.example.com/privkey.pem;
  ssl_trusted_certificate /etc/letsencrypt/live/cors.example.com/chain.pem;
  
  server_name cors.example.com;
  
  sendfile                   on;
  tcp_nodelay                on;
  tcp_nopush                 on;
  
  etag                       off;
  if_modified_since          off;
  
  proxy_buffering            off;
  proxy_cache                off;
  proxy_cache_convert_head   off;
  proxy_max_temp_file_size   0;
  client_max_body_size       0;
  
  proxy_http_version         1.1;
  proxy_pass_request_headers on;
  proxy_pass_request_body    on;
  
  proxy_read_timeout         1m;
  proxy_connect_timeout      1m;
  reset_timedout_connection  on;
  
  proxy_redirect             off;
  resolver                   77.88.8.8 77.88.8.1 8.8.8.8 8.8.4.4 valid=1d;
  
  gzip                       off;
  gzip_proxied               off;
  # brotli                   off;
  
  location / {
    if ($proxy_uri = "") {
      return 403;
    }
    
    # add proxy cors
    add_header Access-Control-Allow-Headers "*" always;
    add_header Access-Control-Allow-Methods "*" always;
    add_header Access-Control-Allow-Origin  "*" always;

    if ($request_method = "OPTIONS") {
      return 204;
    }
    
    # pass client to proxy
    proxy_set_header Host                $proxy_host;
    proxy_set_header Origin              $proxy_origin;
    proxy_set_header X-Real-IP           $remote_addr;
    proxy_set_header X-Client-IP         $remote_addr;
    proxy_set_header CF-Connecting-IP    $remote_addr;
    proxy_set_header Fastly-Client-IP    $remote_addr;
    proxy_set_header True-Client-IP      $remote_addr;
    proxy_set_header X-Cluster-Client-IP $remote_addr;
    proxy_set_header X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto   $scheme;
    proxy_set_header Forwarded           "$proxy_add_forwarded;proto=$scheme";
    
    # hide original cors
    proxy_hide_header Access-Control-Allow-Credentials;
    proxy_hide_header Access-Control-Allow-Headers;
    proxy_hide_header Access-Control-Allow-Methods;
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Expose-Headers;
    proxy_hide_header Access-Control-Max-Age;
    proxy_hide_header Access-Control-Request-Headers;
    proxy_hide_header Access-Control-Request-Method;
    
    proxy_pass $proxy_uri;
  }
}

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


  1. ProstakovAlexey
    13.02.2022 17:15
    +3

    Как всегда, есть нюансы. Опытный фронтовик воткнет где нибудь:

    request.credentials;

    и браузер получив в ответе

    Access-Control-Allow-Methods "*"

    обругается. Так что лучше сразу разобраться с CORS на стороне back, иначе этот костыль с nginx стукнет по голове в неожиданный момент. А так пусть менеджер подумает, подоговаривается с авторами back, обычно в таких переговорах $ позволяют решить проблемку.


    1. petrov_engineer Автор
      13.02.2022 19:24

      Это не всегда решение, например, если нужно взаимодействовать со сторонним API. Что в таком случае делать изволите? Скажем, если есть поддержка JSONP это одно, а если нет?

      По поводу кредов и кук, можно поддержать, использовав proxy_cookie_domain, но вообще это отдельная тема, как их правильно преобразовывать и обрабатывать.


  1. mavir
    13.02.2022 19:24
    +3

    Как выше написали, ставить звездочку может обернуться проблемами в браузере. Если хотите разрешить всем доступ, то лучше вставить такие правила

    add_header 'Access-Control-Allow-Origin' "$http_origin" always;
    add_header 'Access-Control-Allow-Headers' "$http_access_control_request_headers" always;
    add_header 'Access-Control-Request-Method' "$http_access_control_request_method" always;

    Т.е. в ответ будут отдаваться только запрашиваемые параметры. Если, например, Origin не передавался, то и не получите в ответе залоговок Access-Control-Allow-Origin


    1. ProstakovAlexey
      13.02.2022 20:14

      не знаю сработает ли такая конфигурация nginx, но идея подставить вместо * домен из запроса выглядит вполне рабочей. Я так делал на helidon 1, у которого не было CORS из коробки, работало нормально. Методы и заголовки правда харкодил.


      1. mavir
        13.02.2022 20:50
        +1

        Звездочка может приводить к подобной ошибке в браузере

        Запрос из постороннего источника заблокирован: Политика одного источника запрещает чтение удаленного ресурса на «http://xxxx.yyy/». (Причина: Учётные данные не поддерживаются, если заголовок CORS «Access-Control-Allow-Origin» установлен в «*»).


  1. RekGRpth
    14.02.2022 05:18

    Нашёл на эту тему пару древних плагинов https://github.com/x-v8/ngx_http_cors_filter и https://github.com/detailyang/ngx_http_cors_filter


  1. tendium
    14.02.2022 10:28
    +1

    Вы изобрели api gateway. Верно я понял?


  1. uyrij
    14.02.2022 14:31

    выше почти всё сказали, вообще ++, идея рабочая, добавлю, что в гитхабе можно найти многозвездный API gateway в готовом докер образе на альпине. Что удобнее, чем просто конфиг


  1. PlatinumThinker
    15.02.2022 08:59
    +2

    Кажется плохой идеей позволять ходить куда угодно с твоего nginx — для тестового стенда только если и сгодится. На проде такое использовать опасно т.к. вы по факту делаете общедоступную http проксю