Безопасность
Исходный кодЛюбое программное обеспечение, доступное из публичного интернета, будет подвергаться атакам. Большинство приложений Phoenix относятся к этой категории, поэтому полезно понимать распространённые типы уязвимостей веб-приложений, их последствия и способы защиты от них. Рассмотрим следующий сценарий:
Вы отвечаете за банковское приложение, которое обрабатывает транзакции через бэкенд Phoenix. В результате нового изменения кода появляется уязвимость удалённого выполнения кода (RCE), предоставляющая злоумышленнику доступ к серверу по SSH в производственной среде. В результате база данных похищается, учётные записи пользователей взламываются, а средства клиентов украдены.
С распространением инструментов программирования на основе генеративного ИИ правильная оценка безопасности кода стала важна как никогда. В этом документе подробно рассматривается, как в проекте Phoenix могут возникать серьёзные уязвимости, а также приводятся рекомендации по безопасному программированию, которые помогут избежать инцидентов.
Удалённое выполнение кода (RCE)
Уязвимость удалённого выполнения кода (RCE) предоставляет злоумышленнику доступ к веб-серверу по SSH в производственной среде. Этот тип уязвимости часто считают наихудшим из возможных, поскольку для атаки не требуется взаимодействие с пользователем, а злоумышленник может обойти все средства защиты приложения.
Никогда не передавайте недоверенные пользовательские данные в следующие функции:
# Code functions Code.eval_string/3 Code.eval_file/2 Code.eval_quoted/3 # EEX functions eval_string/3 eval_file/3 # Command injection functions :os.cmd/2 System.cmd/3 System.shell/2
Все эти функции выполняют произвольный код на сервере, если им передать внешние данные. Большинство программистов понимает очевидность этого риска, поэтому приложения Phoenix с такой уязвимостью встречаются редко.
Более распространённый и зачастую неожиданный способ появления RCE-уязвимости в приложении Phoenix связан с функцией Erlang binary_to_term. Из документации Erlang:
При декодировании двоичных данных из недоверенных источников эти источники могут передать данные таким образом, чтобы создать ресурсы, например атомы и удалённые ссылки, которые не могут быть удалены сборщиком мусора и могут привести к атаке типа «отказ в обслуживании» (DoS). В таких случаях используйте
binary_to_term/2с параметромsafe.
Это предупреждение сбивает с толку, поскольку упомянутый выше параметр safe лишь предотвращает создание новых атомов во время выполнения. Он не предотвращает создание исполняемых термов с помощью вредоносных пользовательских данных, что представляет гораздо большую опасность.
# Not safe :erlang.binary_to_term(user_input, [:safe]) # Safe Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])
Функция Plug.Crypto.non_executable_binary_to_term предотвращает создание исполняемых термов во время выполнения. Если вам интересно, как такая уязвимость может возникнуть на практике, ознакомьтесь с описанием CVE-2020-15150 в библиотеке Paginator.
SQL-инъекция
SQL-инъекция по степени серьёзности не уступает RCE: она может привести к утечке всей базы данных и даже в некоторых случаях использоваться для выполнения кода. Хорошая новость для разработчиков Phoenix заключается в том, что большинство приложений использует Ecto — интерфейс к специализированной базе данных, обычно PostgreSQL или MySQL. Синтаксис запросов Ecto по умолчанию защищает от SQL-инъекций:
# Safe, using query syntax
def a_get_fruit(min_q) do
from(
f in Fruit,
where:
f.quantity >= ^min_q and
f.secret == false
)
|> Repo.all()
end
Функция fragment может показаться способом проведения SQL-инъекции:
# Fails to compile
def b_get_fruit(min_q) do
from(
f in Fruit,
where: fragment("f0.quantity >= #{min_q} AND f0.secret = FALSE")
)
|> Repo.all()
end
Однако попытка скомпилировать приведённый выше код завершится ошибкой:
Compiling 1 file (.ex)
== Compilation error in file lib/basket/goods.ex ==
** (Ecto.Query.CompileError) to prevent SQL injection attacks, fragment(...)
does not allow strings to be interpolated as the first argument via the `^`
operator, got: `"f0.quantity >= #{min_q} AND f0.secret = FALSE"`
А что, если передать аргументы через fragment?
# Safe
def c_get_fruit(min_q) do
min_q = String.to_integer(min_q)
from(
f in Fruit,
where: fragment("f0.quantity >= ? AND f0.secret = FALSE", ^min_q)
)
|> Repo.all()
end
Приведённый выше код безопасен, поскольку внешние пользовательские данные безопасно параметризуются в запросе. Что, если передавать аргументы напрямую в SQL-запрос?
# Safe
def d_get_fruit(min_q) do
q = """
SELECT f.id, f.name, f.quantity, f.secret
FROM fruits AS f
WHERE f.quantity > $1 AND f.secret = FALSE
"""
{:ok, %{rows: rows}} =
Ecto.Adapters.SQL.query(Repo, q, [String.to_integer(min_q)])
end
Приведённый выше код безопасен, как и пример с fragment, поскольку пользовательские данные безопасно параметризуются.
Формирование строки SQL-запроса на основе пользовательских данных с последующей передачей её напрямую в функцию query приводит к SQL-инъекции:
# Vulnerable to SQL injection
def e_get_fruit(min_q) do
q = """
SELECT f.id, f.name, f.quantity, f.secret
FROM fruits AS f
WHERE f.quantity > #{min_q} AND f.secret = FALSE
"""
{:ok, %{rows: rows}} =
Ecto.Adapters.SQL.query(Repo, q)
end
Если вам приходится писать SQL-код напрямую, не подставляйте данные непосредственно в строку, а используйте параметры для передачи внешних данных в запрос.
Подделка серверных запросов (SSRF)
Подделка серверных запросов (SSRF) — критическая уязвимость, которая становилась причиной крупных утечек данных. Проблема заключается в использовании недоверенных пользовательских данных для отправки исходящих HTTP-запросов, что позволяет атаковать сервисы, доступные из вашего приложения Phoenix.
При создании сервера у большинства современных облачных провайдеров, например AWS EC2, по частному IP-адресу доступен сервис метаданных. В случае AWS он доступен по адресу 169.254.169.254. Если отправить HTTP-запрос на адрес:
http://169.254.169.254/iam/security-credentials
С сервера в ответ будут получены учётные данные аккаунта AWS. Если вы создадите веб-приложение (например, на Phoenix), в котором пользователь может указать URL, а затем просмотреть ответ на HTTP-запрос, отправленный по этому URL, такая функциональность потенциально уязвима к SSRF. Реальный пример подобного инцидента — утечка данных Capital One.
Ваша реакция может быть такой: «Похоже, это небезопасная функция, раз в её честь назвали целый класс уязвимостей». Вы не одиноки в этом мнении: в 2019 году AWS внедрила меры защиты от SSRF, представив IMDSv2, который требует аутентификации сеанса для HTTP-запросов к конечной точке метаданных.
Сервис метаданных облачных серверов — не единственная цель SSRF: уязвимыми могут быть такие сервисы, как PostgreSQL, MySQL, Redis, Elasticsearch и даже собственные микросервисы, которые, как вам кажется, недоступны из публичного интернета. Представьте внутренний сервер с уязвимостями, которыми можно воспользоваться через HTTP-запросы, но который не подключён напрямую к публичному интернету. Однако если в этой же сети работает уязвимое к SSRF приложение Phoenix, злоумышленник может использовать сервер Phoenix для атаки на внутренний сервер.
Подведём итог: использование HTTP-клиента не обязательно означает, что ваше приложение уязвимо к SSRF. Для этого должны выполняться следующие условия:
- Вы используете внешние пользовательские данные для формирования URL
- Злоумышленник может отправлять HTTP-запросы какому-либо уязвимому серверу в вашей сети
В качестве примера для Elixir рассмотрим библиотеку Req:
req = Req.new(base_url: "https://api.github.com") Req.get!(req, url: "/repos/sneako/finch").body["description"] #=> "Elixir HTTP client, focused on performance"
Значение base_url можно переопределить:
req = Req.new(base_url: "https://elixir-lang.org/") user_input = "https://dashbit.co/blog" Req.get!(req, url: user_input)
Приведённый выше код отправляет запрос на https://dashbit.co/blog, а НЕ на https://elixir-lang.org/.
Рассмотрим аналогичный пример с Tesla:
plug Tesla.Middleware.BaseUrl, "https://example.com/foo"
MyClient.get("http://example.com/bar") # equals to GET http://example.com/bar
Даже если в приложении задан базовый URL, не считайте его средством защиты: как показывают эти примеры, его можно переопределить. По возможности избегайте отправки HTTP-запросов на основе пользовательских данных и помните о сервисах в той же сети, что и ваше приложение Phoenix, которыми можно воспользоваться через SSRF.
Неправильная настройка междоменного обмена ресурсами (CORS)
Междоменный обмен ресурсами (CORS) — функция современных веб-браузеров, позволяющая обходить политику одного источника. Это полезно, поскольку теперь приложение может загружать ресурсы с другого сайта в браузере пользователя. По умолчанию такая загрузка обычно запрещена.
Рассмотрим API Phoenix с одностраничным приложением (SPA) на другом домене:
app.example.com — фронтенд на React
api.example.com — бэкенд на Elixir/Phoenix
Фронтенду (app.example.com) нужно получить сведения о текущем пользователе, однако источники этих проектов различаются. Браузер пользователя заблокирует запрос, если не включить CORS для этих сайтов.
Следующая настройка в приложении Phoenix:
plug CORSPlug, origin: ["https://app.example.com"]
— правильный способ решения этой задачи. Однако можно также задать слишком широкую политику, например:
plug CORSPlug, origin: ~r/^http.*/
Это представляет серьёзную угрозу безопасности: теперь вредоносный сайт, открытый пользователем, вошедшим в приложение, может считывать конфиденциальные данные, например ключи API. Убедитесь, что здесь разрешены только доверенные сайты.
Нарушение контроля доступа
В контроллерах Phoenix стандартный способ обработки авторизации — поместить текущего пользователя в assigns, а затем выполнить:
user = conn.assigns.current_user
Во многих проектах это добавляют в качестве третьего аргумента в действия контроллера:
def action(conn, _) do args = [conn, conn.params, conn.assigns.current_user] apply(__MODULE__, action_name(conn), args) end
Это правильный подход. Неправильно принимать произвольные пользовательские данные при принятии решений об авторизации, например:
# Not safe
def index(conn, %{"user" => user_email})
user = Accounts.get_user_by_email(user_email)
В приведённом выше примере злоумышленнику достаточно изменить отправленную строку user_email на произвольное значение, чтобы выполнить действие от имени другого пользователя. Использование conn.assigns.current_user позволяет избежать этой проблемы.
С этой концепцией связан и дизайн Ecto в Phoenix: в нём учитывается риск массового присваивания, поскольку необходимо явно определять, какие параметры разрешено задавать из предоставленных пользователем данных. Рассмотрим простую схему пользователя:
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :naive_datetime
field :is_admin, :boolean
timestamps(type: :utc_datetime)
end
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :is_admin])
|> validate_email()
|> validate_password(opts)
end
Предположим, что соответствующая форма регистрации доступна из публичного интернета. Видите уязвимость? Проблема в том, что :is_admin ни в коем случае нельзя задавать на основе внешних пользовательских данных. Теперь любой пользователь из публичного интернета может создать учётную запись, у которой в базе данных значение :is_admin будет равно true, хотя разработчик, вероятно, этого не планировал.
Межсайтовый скриптинг (XSS)
По умолчанию пользовательские данные в Phoenix экранируются, поэтому:
<%= "<hello>" %>
отображается в браузере так:
<hello>
Обратите внимание: с точки зрения конечного пользователя это выглядит как обычная строка <hello>; < и > видны только при просмотре страницы с помощью инструментов браузера.
Эту защиту можно обойти с помощью функции raw/1. Например, рассмотрим строку <b>hello</b>.
# This will render the literal string <b>hello</b> <%= "<b>hello</b>" %> # This will render a bold hello <%= raw "<b>hello</b>" %>
Получив возможность внедрять теги script, злоумышленник может выполнять JavaScript в браузере жертвы, например отправив строку вида:
<script>alert(1)</script>
Если кто-то отправит приведённые выше данные в ваше приложение, а они отобразятся у вошедших в систему пользователей, у вас возникнет серьёзная проблема. Реальный пример — червь MySpace 2005 года.
Пользовательские данные никогда не следует передавать в функцию raw/1. В приложениях Phoenix возможны и другие способы внедрения XSS. Например, рассмотрим следующие функции контроллера:
def html_resp(conn, %{"i" => i}) do
html(conn, "<html><head>#{i}</head></html>")
end
Поскольку HTML создаётся на основе пользовательских данных, функция уязвима к XSS. Такое также может произойти с put_resp_content_type:
def send_resp_html(conn, %{"i" => i}) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, "#{i}")
end
Загрузка файлов тоже может привести к XSS.
def new_upload(conn, _params) do
render(conn, "new_upload.html")
end
def upload(conn, %{"upload" => upload}) do
%Plug.Upload{content_type: content_type, filename: filename, path: path} = upload
ImgServer.put(filename, %{content_type: content_type, bin: File.read!(path)})
redirect(conn, to: Routes.page_path(conn, :view_photo, filename))
end
def view_photo(conn, %{"filename" => filename}) do
case ImgServer.get(filename) do
%{content_type: content_type, bin: bin} ->
conn
|> put_resp_content_type(content_type)
|> send_resp(200, bin)
_ ->
conn
|> put_resp_content_type("text/html")
|> send_resp(404, "Not Found")
end
end
Пользовательские данные определяют значение content-type файла. Тип загружаемого файла никак не проверяется, поэтому можно задать content-type так, чтобы файл отображался как HTML-страница. В этом и заключается причина уязвимости. Рассмотрим файл xss.html со следующим содержимым:
<html><script>alert(1)</script></html>
В результате в браузере жертвы, просматривающей изображение, будет выполнен JavaScript. Если ограничить аргумент put_resp_content_type только файлами изображений, уязвимость будет устранена.
Межсайтовая подделка запроса (CSRF)
Стандартная CSRF-атака через HTML-формы
Межсайтовая подделка запроса (CSRF) — уязвимость, возникающая из-за особенностей работы веб-браузеров. Рассмотрим социальную сеть, уязвимую к CSRF. Злоумышленник создаёт вредоносный сайт, нацеленный на настоящих пользователей. Когда жертва посещает этот сайт, он инициирует в её браузере POST-запрос и отправляет сообщение, написанное злоумышленником. В результате в аккаунте жертвы появляется публикация, созданная злоумышленником.
Но почему это возможно? Разве веб-браузер не должен блокировать POST-запросы, инициированные другим сайтом? Эту проблему решает механизм cookie под названием SameSite, однако полезно понимать, что такое CSRF и почему в Phoenix предусмотрена встроенная защита.
Рассмотрим следующую форму:
<form action="/posts" method="post">
<label for="post_title">Title</label>
<input id="post_title" name="post[title]" type="text">
<label for="post_body">Body</label>
<textarea id="post_body" name="post[body]">
</textarea>
<div>
<button type="submit">Save</button>
</div>
</form>
Она соответствует следующему HTTP-запросу:
POST /posts HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Content-Length: 53 post[title]=My+Title&post[body]=This+is+the+body
Злоумышленник может разместить на attacker.com следующую форму:
<form action="https://example.com/posts" method="post">
<label for="post_title">Title</label>
<input id="post_title" name="post[title]" type="text" value="Attacker post">
<label for="post_body">Body</label>
<textarea id="post_body" name="post[body]">This post was written by the bad guy</textarea>
<div>
<button type="submit">Save</button>
</div>
</form>
Обратите внимание: форма даже не обязательно должна быть видна пользователю-жертве. Когда пользователь посещает attacker.com, форма автоматически отправляет POST-запрос к уязвимому сайту от имени жертвы, используя её текущую cookie сеанса.
Большинство веб-фреймворков, включая Phoenix, защищаются от этой уязвимости, требуя CSRF-токен при отправке формы.
<!-- A typical CSRF token seen in a Phoenix form --> <input name="_csrf_token" type="hidden" hidden="" value="WUZXJh07BhAIJ24jP1d-KQEpLwYmMDwQ0-2eYNLH_x8oHoO_qv_HJDqZ">
Теперь HTTP-запрос выглядит так:
POST /posts HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Content-Length: 53 post[title]=My+Title&post[body]=This+is+the+body&post[_csrf_token]=WUZXJh07BhAIJ24jP1d-KQEpLwYmMDwQ0-2eYNLH_x8oHoO_qv_HJDqZ
Поскольку токен генерируется случайным образом, злоумышленник не может предсказать его значение. Приложение защищено от CSRF-атак, поскольку Phoenix по умолчанию проверяет значение этого токена с помощью plug :protect_from_forgery, включённого в конвейер :browser нового проекта Phoenix по умолчанию:
# router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {CarafeWeb.LayoutView, :root}
plug :protect_from_forgery # <-- Checks the CSRF token value
plug :put_secure_browser_headers
plug :fetch_current_user
end
Защита от CSRF по умолчанию встроена в Phoenix, поэтому, если вы используете стандартные настройки, ваше приложение, скорее всего, не уязвимо.
CSRF-атака через повторное использование действия
В большинстве описаний CSRF основное внимание уделяется изменяющим состояние POST-запросам и необходимости случайных токенов, о которых мы только что говорили. Возможна ли CSRF-атака с GET-запросом? Да. GET-запросы ни в коем случае нельзя использовать для выполнения действий, изменяющих состояние веб-приложения (размещение заказа, перевод денег и т. д.), поскольку их нельзя защитить от CSRF так же, как POST-запросы.
Рассмотрим следующую форму:
<.form :let={f} for={@bio_changeset} action={~p"/users/settings/edit_bio"} method="post" id="edit_bio">
<div class="pt-4">
<%= label f, :bio %>
<%= textarea f, :bio, required: true, class: "" %>
<%= error_tag f, :bio %>
</div>
<div>
<%= submit "Update Bio", class: "" %>
</div>
</.form>
Отправка этой формы инициирует POST-запрос:
[info] POST /users/settings/edit_bio
[debug] Processing with CarafeWeb.UserSettingsController.edit_bio/2
Parameters: %{"_csrf_token" => "cigMDlcdAnxbWW88OAAiBHoZZ3cmCW0l1refzZ7D666RWvjCLi02UZXq", "user" => %{"bio" => "My bio here"}}
Pipelines: [:browser, :require_authenticated_user]
Важно, что приведённые выше параметры соответствуют маршруту и действию контроллера:
# router.ex
post "/users/settings/edit_bio", UserSettingsController, :edit_bio
# user_settings_controller.ex
def edit_bio(conn, %{"user" => params}) do
case Accounts.update_user_bio(conn.assigns.current_user, params) do
{:ok, _bio} ->
conn
|> put_flash(:info, "Bio Update Successful")
|> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, changeset} ->
render(conn, "edit.html", bio_changeset: changeset)
end
end
Параметры:
%{"_csrf_token" => "cigMDlcdAnxbWW88OAAiBHoZZ3cmCW0l1refzZ7D666RWvjCLi02UZXq", "user" => %{"bio" => "My bio here"}}
соответствуют действию edit_bio/2. Обратите внимание: _csrf_token обрабатывается в маршрутизаторе встроенным в Phoenix plug :protect_from_forgery.
Теперь рассмотрим следующие изменения в маршрутизаторе:
# router.ex get "/users/settings/edit_bio", UserSettingsController, :edit_bio post "/users/settings/edit_bio", UserSettingsController, :edit_bio
Разрешение доступа к действию контроллера edit_bio/2 через GET-запрос создаёт уязвимость CSRF, связанную с повторным использованием действия. Если жертва перейдёт по следующему URL:
/users/settings/edit_bio?user%5Bbio%5D=Hacked+LOL
Это приведёт к следующему:
[info] GET /users/settings/edit_bio
[debug] Processing with CarafeWeb.UserSettingsController.edit_bio/2
Parameters: %{"user" => %{"bio" => "Hacked LOL"}}
Pipelines: [:browser, :require_authenticated_user]
Эти параметры соответствуют действию контроллера edit_bio/2 и обновляют биографию пользователя. Главный вывод: действия, изменяющие состояние (перевод денег, создание публикации, изменение данных учётной записи), должны выполняться через POST-запрос с надлежащей защитой от CSRF, а не через GET-запрос.
Дополнительная литература
Фонд экосистемы Erlang также публикует подробные документы, полезные разработчикам Erlang, Elixir и Phoenix. Среди них:
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/security.html