Разработка → Gixy — open source от Яндекса, который сделает конфигурирование Nginx безопасным

dharrya 28 апреля в 17:11 28,9k
Nginx, однозначно, один из крутейших веб-серверов. Однако, будучи в меру простым, довольно расширяемым и производительным, он требует уважительного отношения к себе. Впрочем, это относится к почти любому ПО, от которого зависит безопасность и работоспособность сервиса. Признаюсь, нам нравится Nginx. В Яндексе он представлен огромным количеством инсталляций с разнообразной конфигурацией: от простых reverse proxy до полноценных приложений. Благодаря такому разнообразию у нас накопился некий опыт его [не]безопасного конфигурирования, которым мы хотим поделиться.



Но обо всем по порядку. Нас давно терзал вопрос безопасного конфигурирования Nginx, ведь он — полноправный кубик веб-приложения, а значит, и его конфигурация требует не меньшего контроля с нашей стороны, чем код самого приложения. В прошлом году нам стало очевидно, что этот процесс требует серьезной автоматизации. Так начался in-house проект Gixy, требования к которому мы обозначили следующим образом:

— быть простым;
— но расширяемым;
— с возможностью удобного встраивания в процессы тестирования;
— неплохо бы уметь резолвить инклюды;
— и работать с переменными;
— и про регулярные выражения не забыть.

Признаться, мы до последнего колебались с выбором языка (между Golang и Python). В итоге был выбран Python с надеждой на то, что он более распространен, а значит, будет чуть проще с развитием.

О проблемах


На этом покончим со вступлением и перейдем к примерам распространенных проблем :) Чтобы избежать путаницы в будущем, во всех примерах использовалась текущая mainline версия Nginx — 1.13.0.

Server-Side-Request-Forgery

Server Side Request Forgery — уязвимость, позволяющая выполнять различного рода запросы от имени веб-приложения (в нашем случае от имени Nginx). Возникает, когда злоумышленник может контролировать адрес проксируемого сервера — например, в случае некорректной настройки XSendfile.

По своему опыту могу сказать, что зачастую уязвимость связана с несколькими ошибками:

— отсутствие директивы internal. Ее смысл заключается в указании того, что определенный location может использоваться только для внутренних запросов;
— небезопасное внутреннее перенаправление.

Если с первым случаем все понятно, то с внутренним перенаправлением дела обстоят не так просто. Полагаю, многие из вас видели/писали подобную конфигурацию:

location ~* ^/internal-proxy/(?<proxy_proto>https?)/(?<proxy_host>.*?)/(?<proxy_path>.*)$ {
    internal;

    proxy_pass $proxy_proto://$proxy_host/$proxy_path ;
    proxy_set_header Host $proxy_host;
}

К сожалению, в такой конфигурации вам необходимо проверить как минимум все директивы rewrite и try_files, так как согласно документации:

Внутренними запросами являются:
– запросы, перенаправленные директивами error_page, index, random_index и try_files;
– запросы, перенаправленные с помощью поля “X-Accel-Redirect” заголовка ответа вышестоящего сервера;
– подзапросы, формируемые командой “include virtual” модуля ngx_http_ssi_module и директивами модуля ngx_http_addition_module;
– запросы, изменённые директивой rewrite.

Получается, любой неосторожный реврайт позволит сделать запрос в internal location. В этом довольно легко убедиться:

– конфигурация:

location ~* ^/internal-proxy/(?<proxy_proto>https?)/(?<proxy_host>.*?)/(?<proxy_path>.*)$ {
    internal;

    return 200 "proto: $proxy_proto\nhost: $proxy_host\npath: $proxy_path";
}

rewrite ^/(?!_api)(.*)/\.files/(.*)$ /$1/.download?file=$2 last;

– эксплуатация:

GET /internal-proxy/http/evil.com/.files/some HTTP/1.0
Host: localhost



HTTP/1.1 200 OK
Content-Length: 42
Content-Type: application/octet-stream
Date: Fri, 28 Apr 2017 13:55:51 GMT
Server: nginx/1.13.0

proto: http
host: evil.com
path: .download

В данной ситуации мы обычно рекомендуем несколько практик:

— использовать только internal location для проксирования;
— по возможности запретить передачу пользовательских данных;
— обезопасить адрес проксируемого сервера:

• если количество проксируемых хостов ограничено (например, у вас S3), то лучше их захардкодить и выбирать при помощи map или иным удобным для вас образом;
• если по какой-то причине нет возможности перечислить все возможные хосты для проксирования, его стоит подписать.

Плохие регулярные выражения для валидации реферера или ориджина

У вас есть проблема. Вы решили использовать регулярные выражения, чтобы её решить.
– Теперь у вас две проблемы.

Нередко валидация заголовка запроса «Referer» или «Origin» делается при помощи регулярного выражения. Зачастую это необходимо для условного выставления заголовка X-Frame-Options (защита от ClickJacking) или реализации Cross-Origin Resource Sharing (CORS). И если с валидацией «Referer» все немного проще и, с некоторыми условиями, можно отказаться от регулярного выражения в пользу модуля ngx_http_referer_module, то с «Origin» не все так однозначно.

Мы выделяем два основных класса проблем:

— ошибки в составлении регулярного выражения;
— разрешение недоверенных third-party доменов.

Проблемная конфигурация выглядит следующим образом:

if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)/)) {
	add_header 'Access-Control-Allow-Origin' "$http_origin";
	add_header 'Access-Control-Allow-Credentials' 'true';
}

На самом деле я очень упростил регулярное выражение, но даже в этом примере увидеть проблему с первого раза не столь просто. Людям проще писать регулярные выражения, нежели читать их.

К счастью, машине несвойственна эта проблема, поэтому Gixy умеет самостоятельно определять, что это регулярное выражение сматчит www.yandex.ru.evil.com как валидный origin и сообщит вам об этом:

$ gixy --origins-domains yandex.ru,ya.ru /etc/nginx/nginx.conf

==================== Results ===================

Problem: [origins] Validation regex for "origin" or "referrer" matches untrusted domain.
Description: Improve the regular expression to match only trusted referrers.
Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/origins.md
Reason: Regex matches "https://www.yandex.ru.evil.com" as a valid origin.
Pseudo config:
include /etc/nginx/sites/default.conf;

	server {
		server_name _;

		if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)/)) {
		}
	}

Или, если считать ya.ru недостаточно доверенным, сообщит о ориджинах ya.ru и www.yandex.ru.evil.com:

$ gixy --origins-domains yandex.ru /etc/nginx/nginx.conf

==================== Results ===================

Problem: [origins] Validation regex for "origin" or "referrer" matches untrusted domain.
Description: Improve the regular expression to match only trusted referrers.
Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/origins.md
Reason: Regex matches "https://www.yandex.ru.evil.com", "https://ya.ru/" as a valid origin.
Pseudo config:
include /etc/nginx/sites/default.conf;

	server {
		server_name _;

		if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)/)) {
		}
	}

HTTP Splitting

HTTP Splitting используется для атак на приложение, стоящее за Nginx (HTTP Request Splitting), или на клиентов приложения (HTTP Response Splitting). Уязвимость возникает в случае, когда атакующий может внедрить символ перевода строки \n в запрос или ответ, формируемый Nginx.

Безотказного совета (кроме как быть внимательными) у меня нет, но всегда следует обращать внимание на несколько вещей:

— какие переменные используются в директивах, отвечающих за формирование запросов (могут ли они содержать CRLF), например: rewrite, return, add_header, proxy_set_header и proxy_pass;
— используются ли переменные $uri и $document_uri, и если да, то в каких директивах, так как они гарантированно содержат урлдекодированное значение;
— уделить особое внимание переменным, полученным из групп с исключающим диапазоном: (?P[^.]+).

Пример с исключающим диапазоном:

— конфигурация:

server {
    listen 80 default;

    location ~ /v1/((?<action>[^.]*)\.json)?$ {
        add_header X-Action $action;
        return 200 "OK";
    }
}

— эксплуатация:

GET /v1/see%20below%0d%0ax-crlf-header:injected.json HTTP/1.0
Host: localhost



HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/octet-stream
Date: Fri, 28 Apr 2017 13:57:28 GMT
Server: nginx/1.13.0
X-Action: see below
x-crlf-header: injected

OK

Как вы видите, мы смогли добавить заголовок ответа x-crlf-header: injected. Это случилось благодаря стечению нескольких обстоятельств:

— add_header не кодирует/валидирует переданные ему значения, считая, что автор знает о последствиях;
— значение пути нормализуется перед обработкой локейшена;
— переменная $action была выделена из группы регулярного выражения с исключающим диапазоном: [^.]*;
— таким образом, значение переменной $action стало равно see below\r\nx-crlf-header:injected и попало в HTTP-ответ.

К счастью, Gixy с немалым успехом справляется с этой задачей:

— он знает об «опасных» переменных — точнее, он знает о допустимом множестве символов в большинстве встроенных переменных. Таким образом, отличие $request_uri от $uri для него очевидно;
— умеет выделять переменные из групп регулярного выражения;
— умеет определять, может ли какой-либо символ (в нашем случае \n) сматчиться регулярным выражением (или отдельно взятой группой).

Другой интересный пример — реврайт с помощью try_files:

— конфигурация:

server {
    listen 80 default;

    location / {
        try_files $uri $uri/ /index.php?q=$uri;
    }

    location ~ \.php {
        proxy_set_header X-Real-IP  $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header Host $host;
        proxy_pass http://127.0.0.1:9000;
    }
}

— эксплуатация (на 127.0.0.1:9000 слушает отладочный echo-сервер):

GET /request%20HTTP/1.0%0aInjection: HTTP/1.0
Host: localhost



HTTP/1.1 200 Ok
Content-Length: 244
Content-Type: text/plain
Date: Fri, 28 Apr 2017 13:59:18 GMT
Server: nginx/1.13.0

GET /index.php?q=/request HTTP/1.0\n
Injection: HTTP/1.0\r\n
X-Real-IP: 127.0.0.1\r\n
X-Forwarded-For: 127.0.0.1\r\n
Host: localhost\r\n
Connection: close\r\n
User-Agent: HTTPie/0.9.8\r\n
Accept-Encoding: gzip, deflate\r\n
Accept: */*\r\n
\r\n

Что делать?

— Старайтесь использовать более безопасные переменные, например $request_uri вместо $uri.
— Запретите перевод строки в исключающем диапазоне, например /some/(?[^/\s]+) вместо /some/(?[^/]+.
— Возможно, хорошей идеей будет добавить валидацию $uri (только если вы знаете, что делаете).

Переопределение «вышестоящих» заголовков ответа директивой add_header

Это известная особенность Nginx, о которую спотыкались и будут продолжать спотыкаться многие из нас. Суть крайне проста — если у вас устанавливаются заголовки на одном уровне (например, в серверной секции), а уровнем ниже (например, в локейшене) устанавливаются какие-либо еще, то первый не будет применен.

Наиболее простой пример выглядит следующим образом:

server {
    listen 80 default;
    server_name _;

    add_header X-Content-Type-Options nosniff;
    location / {
        add_header X-Frame-Options DENY;
    }
}

В данном случае заголовок ответа X-Content-Type-Options не будет установлен при обработке локейшена /.

Gixy с успехом расскажет вам об этом:

$ gixy /etc/nginx/nginx.conf

==================== Results ===================

Problem: [add_header_redefinition] Nested "add_header" drops parent headers.
Description: "add_header" replaces ALL parent headers. See documentation: http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header
Additional info: https://github.com/yandex/gixy/blob/master/docs/ru/plugins/addheaderredefinition.md
Reason: Parent headers "x-content-type-options" was dropped in current level
Pseudo config:
include /etc/nginx/sites/default.conf;

	server {
		server_name _;
		add_header X-Content-Type-Options nosniff;

		location / {
			add_header X-Frame-Options DENY;
		}
	}

Мне известно несколько способов решить эту проблему:

— продублировать важные заголовки;
— устанавливать заголовки на одном уровне, например в серверной секции;
— рассмотреть вариант с использованием модуля ngx_headers_more.

Каждый из них имеет свои преимущества и недостатки. Какой предпочесть, зависит от вас.

О Gixy


Надеюсь, я вас убедил в том, что конфигурация Nginx требует более пристального внимания. Я также верю в то, что статический анализ конфигураций Nginx может работать (это также подтверждает опыт Nginx Amplify). К сожалению, не всегда есть возможность автоматически определить все пограничные случаи или специфичные особенности приложения, стоящего за Nginx. Так, к примеру, я не стал включать в стандартный набор проверку переопределения заголовков запроса X-Forwarded-*, так как реакция на них зависит от приложения, а в некоторых случаях к ним и вовсе нельзя притрагиваться (например, при множественном проксировании). Но у себя вы можете сделать нужные вам проверки, основываясь на более глубоком понимании работы приложения. Да, сейчас Gixy не умеет определять весь спектр известных нам проблем, но учится и, возможно, с вашей помощью начнет делать это лучше и полнее.

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

— запуск в тестовой среде, где установлен nginx;
— веб-приложение для проверки отдельно взятого блока. Это бывает полезно, когда вам встретился подозрительный участок конфига;
— HTTP API для интеграции с CI или тонкими клиентами.

Нам кажется, что наиболее интересен вариант с использованием HTTP API для тонких клиентов. Ведь в таком случае мы можем централизованно управлять нужными нам проверками, обновлять их и так далее. К счастью, современные версии nginx обладают ключом -T для тестирования конфигурации и дампа оной, а Gixy умеет парсить этот формат.

Сами посудите, насколько это удобно
$ nginx -T | http -v https://gixy/api/check Content-Type:'application/nginx'
POST /api/check HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 959
Content-Type: application/nginx
Host: gixy
User-Agent: HTTPie/0.9.8

# configuration file /etc/nginx/nginx.conf:
user http;
worker_processes 1;

#daemon on;
events {
worker_connections 1024;
}

http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
access_log /var/log/nginx/access.log combined;
error_log /var/log/nginx/error.log debug;

include sites/*.conf;
}

# configuration file /etc/nginx/mime.types:
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
}

# configuration file /etc/nginx/sites/default.conf:
server {
listen 80;
return 301 https://some$uri;
}

HTTP/1.1 200 OK
Connection: keep-alive
Content-Encoding: gzip
Content-Type: application/json
Date: Tue, 24 Apr 2017 19:45:57 GMT
Keep-Alive: timeout=120
Server: nginx
Transfer-Encoding: chunked

{
"result": [
{
"auditor": "http_splitting",
"config": "\ninclude /etc/nginx/sites/default.conf;\n\n\tserver {\n\t\treturn 301 https://some$uri;\n\t}",
"description": "Текущая конфигурация позволяет злоумышленнику внедрить символ перевода строки (\"\\n\") в запрос или ответ формируемый nginx. В первую очередь это касается директив: rewrite, return и proxy_pass.",
"help_url": "https://wiki/product-security/gixy/httpsplitting/",
"reason": "At least variable \"$uri\" can contain \"\\n\"",
"recommendation": "ограничьте допустимый набор символов, зачастую достаточно использовать более безопасное значение (e.g. \"$request_uri\" вместо \"$uri\").",
"severity": "HIGH",
"summary": "Обнаружена уязвимость типа HTTP Splitting"
}
],
"status": "ok",
"warnings": []
}


Напоследок хотелось бы подчеркнуть тот факт, что это первая публичная alpha-версия Gixy, поэтому API может изменяться без сохранения обратной совместимости. В связи с этим, если у вас есть необходимость в реализации собственного плагина, лучше написать Issue или прислать Pull Request — тогда мы вместе что-то придумаем.

Надеюсь, наш опыт был вам интересен и полезен, и, быть может, даже заставил пересмотреть свои конфигурации еще раз;)
Проголосовать:
+112
Сохранить: