Это достаточно старая статья, которая была когда-то давно опубликована в очень закрытом блоге (инвайт получить занимает минимум часов 10!). Планы по публикации её в публичный интернет так и не стали реальностью, поэтому статью мне пришлось стащить и… Шучу, я как минимум у двух человек уточнил (в том числе и у автора), они разрешили -)
Статью люто советую почитать всем — и периодически перечитывать.
Автор статьи — buglloc [https://github.com/buglloc] из лучшей ИБ-команды на Руси -)
Ниже оригинал (я только examples заменил и убрал несколько упоминаний непубличных вещей).
Возможно, какой-то код из примеров уже не заработает, может быть где-то код приложений я форматировал неправильно, бла-бла-бла, но суть статьи от этого особо не теряется, думаю.
В прошлый раз я касался темы различной имплементации парсинга урлов в контексте OpenRedirect’ов. В этот же раз, я хотел бы продолжить схожую тему, но в контексте другой, менее распространенной (но не редко менее очевидной и более критичной) проблемы — контроль доступа при проксировании. Т.е. когда доступом к какой-то конкретной ручке хочется управлять уровнем выше приложения (e.g. nginx).
Суть проблемы, кроется в самом способе решения задачи:
• В приложении есть ручка /secret/place , к которой нужно ограничить доступ
• Не вопрос, добавим локейшен в nginx
В этом месте нужно выдохнуть, откинутся на спинку стула и подумать действительно ли это верный путь (подсказка: в немалом числе случаев вам не удастся это безболезненно реализовать). Парсинг пути/параметров запроса в двух различных приложениях может быть абсолютно произволен, а порой и крайне не очевиден. Что в купе с различными реализациями роутинга в самих приложеньках может иметь абсолютно не предсказуемый эффект.
Типичный список различий в парсинге пути, который может позволить обойти подобное ограничение:
• Путь может урлдекодироваться (e.g, /foo%2fbar -> /foo/bar ), а может и нет. Nginx декодирует.
• Слеши могут «склеиваться» (e.g. //foo -> /foo ), а могут и нет. Nginx клеит.
• Путь может нормализовываться (e.g. /foo/../bar -> /bar , /foo/./bar -> /foo/bar ), а может и нет. Nginx нормализует
• Обработка ошибок может отличаться (e.g. /foo% -> /foo или /foo% или 400ая, а может и вовсе /f%oo -> /foo ). Nginx, к примеру, отбрасывает % в конце, стейтмашина такая 🙁
• Применяется различная, странная нормализация (e.g. /foo\bar -> /foo/bar ). Это, зачастую, проблема реализации в конкретном приложении, фреймворки любят наставить непредсказуемых костылей для странных кейсов. Nginx нормализует \ только под win
Есть и второй, более редкий (но более багоопасный) случай — запретить запросы с определенными параметрами. В этом случае проблемы ровно теже, что и при Http Parameter Contamination/Pollution:
• Наличие или отсутствие урлдекодирования, e.g. имя параметра при запросе fo%6f=bar может быть как foo так и fo%6f . Nginx НЕ декодирует, имена параметров регистронезависимы (e.g. fOo=b%61r это переменная $arg_foo со значением b%61r )
• Различное поведение при дублировании foo=bar&foo=baz — первый, второй, «склеятся», образуют список, что либо еще? Nginx использует ПЕРВОЕ значение при наличии равно, например, для foo&foo=bar&&foo=baz будет $arg_foo = bar
• Различная обработка ошибок и нормализация, e.g. foo[bar=baz -> foo_bar=baz или foo%20=bar -> foo=bar . Nginx ничего не делает в данном случае
В свете всех этих проблем, прошу вас хорошенько подумать прежде чем соглашаться ограничивать доступ к проксируемому запросу. Скорее всего от этой затеи стоит отказаться и сделать ограничение доступа там, где этот запрос обрабатывается (e.g. в приложении, а не в nginx).
Дабы не быть голословным, рассмотрим несколько вариантов…
Входные данные:
• Nginx v1.10.1, NodeJS v6.2.2, Express v4.14.0
• Нужно ограничить доступ к ручке /secret/place
Код приложения:
var app = express();
// Вывод пути, для удобства дебага
function exposePath(req, res, next) {
res.setHeader("X-App-Path", req.path);
next();
}
app.get('/', exposePath, function (req, res) {
res.send('index');
});
app.get('/secret/place', exposePath, function (req, res) {
res.send('secret');
});
app.listen(3000, function () {
console.log('Listening on port 3000!');
});
Базовый конфиг nginx:
listen 80;
server_name cool-service.example.com;
add_header "X-Nginx-ReqUri" $request_uri always;
add_header "X-Nginx-DocUri" $document_uri always;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
Пробуем закрыть доступ при помощи точного совпадения (дабы не зарубить лишнего, ага):
deny all;
}
И с виду все работает:
HTTP/1.1 403 Forbidden
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 13:53:15 GMT
Content-Type: text/html
Content-Length: 169
Connection: close
<html>
<head><title>403 Forbidden</title></head>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.10.1</center>
</body>
</html>
Однако, роутинг в Express сделан таким образом, что слеш в конце опционален, поэтому текущий запрет никому не помешает:
HTTP/1.1 200 OK
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:19:34 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 6
Connection: close
X-Powered-By: Express
X-App-Path: /secret/place/
ETag: W/"6-Xr4ilOzQ4PCOq3aQ0qbuaQ"
X-Nginx-ReqUri: /secret/place/
X-Nginx-DocUri: /secret/place/
secret
Хм, хоккей:) Переделываем на префиксный локейшен:
deny all;
}
Ну вот! Теперь то точно работает:
HTTP/1.1 403 Forbidden
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:20:51 GMT
Content-Type: text/html
Content-Length: 169
Connection: close
<html>
<head><title>403 Forbidden</title></head>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.10.1</center>
</body>
</html>
Правда, стоит еще раз взглянуть на реализацию роутинга в Express и увидеть, что он регистронезавизим, в то время как в nginx префиксный локейшен регистрозависим:
HTTP/1.1 200 OK
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:31:25 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 6
Connection: close
X-Powered-By: Express
X-App-Path: /secret/placE
ETag: W/"6-Xr4ilOzQ4PCOq3aQ0qbuaQ"
X-Nginx-ReqUri: /secret/placE
X-Nginx-DocUri: /secret/placE
secret
Что ж, переделываем на регистронезависимый regexp:
deny all;
}
Пробуем:
HTTP/1.1 403 Forbidden
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:32:38 GMT
Content-Type: text/html
Content-Length: 169
Connection: close
<html>
<head><title>403 Forbidden</title></head>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.10.1</center>
</body>
</html>
Фуф! Справились! Пора идти за печенюшкой, но не все так просто;) Express использует для парсинга урла запроса модуль parseurl, а там целых два парсера под капотом — «простой» и стандартный. А зная, что стандартный модуль преобразует \ в / , осталось написать запрос, который бы спровоцировал parseurl воспользоваться именно им.
Пробуем раз:
HTTP/1.1 404 Not Found
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:30:39 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 25
Connection: close
X-Powered-By: Express
X-Content-Type-Options: nosniff
Cannot GET /secret\place
Не срослось:( Пробуем два:
HTTP/1.1 200 OK
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:30:51 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 6
Connection: close
X-Powered-By: Express
X-App-Path: /secret/place
ETag: W/"6-Xr4ilOzQ4PCOq3aQ0qbuaQ"
X-Nginx-ReqUri: /secret\place#
X-Nginx-DocUri: /secret\place
secret
То что нужно! 🙂
Устали от NodeJS? Я тоже, поэтому попробуем Flask 🙂
Входные данные:
• Nginx v1.10.1, Python v3.5.1, Flask v0.11.1, Gunicorn
• Нужно ограничить доступ к ручке /secret/place
Код приложения:
from flask import request
app = Flask(__name__)
@app.after_request
def expose_path(response):
response.headers['X-App-Path'] = request.path
return response
@app.route('/')
def index():
return 'index'
@app.route('/secret/place')
def secret_place():
return 'secret'
if __name__ == '__main__':
app.run(host='127.0.0.1', port=3000)
Базовый конфиг nginx:
listen 80;
server_name cool-service.example.com;
add_header "X-Nginx-ReqUri" $request_uri always;
add_header "X-Nginx-DocUri" $document_uri always;
location / {
# Отсутствие слеша в конце - важное условие!
proxy_pass http://127.0.0.1:3000;
}
}
Учитывая опыт с нодой, сразу переходим к последнему варианту:
deny all;
}
Что ж, варианты применяемые ранее не работают (и это отлично!), но не стоит отчаиваться:
HTTP/1.1 200 OK
Server: nginx/1.10.1
Date: Sun, 26 Jun 2016 14:52:56 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 6
Connection: close
X-App-Path: /secret/place
X-Nginx-ReqUri: ///foo/secret/place
X-Nginx-DocUri: /foo/secret/place
secret
Почему это работает? Это результат нескольких причин:
• Необходимо обратить внимание на proxy_pass в конфиге nginx, точнее на отсутствие URI (про отличие в доке: proxy_pass):
proxy_pass http://127.0.0.1:3000;
• Flask запущен Gunicorn’ом, т.к. при использовании uWSGI вы скорее всего будете передавать нормализованный PATH_INFO ($document_uri)
• Flask и Gunicorn парсят строку запроса как полноценную урлу, что дает сайд эффект за счет поддержки scheme-relative URLs
• Таким образом, в запросе ///foo/secret/place foo распарсился как netloc, а /secret/place как путь
И мы благополучно обошли проверку:)
В завершение
Это далеко не полный список проблем и различных вариантов обхода подобного рода ограничений. Это всего лишь небольшая подборка примеров, которая, я надеюсь, должна показать опасность подобной реализации. Большинство техник обхода очень зависит от используемых технологий и их комбинаций, так, например, переход на uWSGI/FastCGI может нивелировать многие проблемы (а может и нет, ибо зависит ^_^) за счет минимизации количества парсеров, но не уберечь от логики роутинга в приложении.
Хотелось бы еще раз сделать акцент на том, что установка ограничений к ручкам на уровне приложения выглядит более безопасной и устойчивой к переменам погоды на Марсе. Если с текущей версией приложения/окружения у вас все работает, это вовсе не означает что это не «разъедется» в будущем 🙁 Не стоит так же забывать, что мы не рекомендуем делать ограничения по IPшкам источничка запроса, лучше разрешать доступ к каким-то доп. ручкам после аутентификации и проверки провязки пользователя.
Upd. По результатам обсуждения в комментах (дисклеймер — комменты забрать из источника нельзя), чуть дополню
• это, ни в коем случае, не призыв отказываться от nginx и спускать _все_ в приложение
• это не относится к функционалу с которым nginx отлично справляется (rate limit, роботоловилка, etc)
• это не относится к случаям, когда средствами nginx делается хотфикс бага, который по каким-то причинам не может быть сиюминутно выложен
• это не призыв убирать дополнительные слои безопасности реализованные при помощи nginx. Наслоение средств защиты это прекрасно
В посте речь идет о тех случаях, когда nginx используется как единственное средство по контролю доступа к отдельно взятым ручкам (эта практика порой используется в сервисах и я хотел предостеречь вас от этого). В данном случае, полагаться только на nginx может иметь неприятные последствия и именно поэтому, принимая решение где это самое ограничение лучше реализовать, стоит еще раз все обдумать и, скорее всего, сделать свой выбор в пользу приложения.
Если же средствами nginx делается какое-то дополнительное ограничение — это просто замечательно, это подход в котором ничего не нужно менять. К примеру:
• ручка доступна неким условным «модераторам» и за это несет ответственность приложение
• nginx же _дополнительно_ ограничивает доступ к этой ручке внутренней сетью
Стоит ли тут что либо менять? Думаю нет 🙂
Комментариев пока нет.