Spec-Zone.ru › Phoenix

Безопасность

Исходный код

Любое программное обеспечение, доступное из публичного интернета, будет подвергаться атакам. Большинство приложений 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. Для этого должны выполняться следующие условия:

  1. Вы используете внешние пользовательские данные для формирования URL
  2. Злоумышленник может отправлять 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>" %>

отображается в браузере так:

&lt;hello&gt;

Обратите внимание: с точки зрения конечного пользователя это выглядит как обычная строка <hello>; &lt; и &gt; видны только при просмотре страницы с помощью инструментов браузера.

Эту защиту можно обойти с помощью функции 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. Среди них:

  • Рекомендации по обеспечению безопасности веб-приложений на языках BEAM

  • Рекомендации по безопасному программированию и защите среды развёртывания

← Предыдущая страница Телеметрия
Следующая страница → 1. Введение в контексты

Пакет Hex Предварительный просмотр Hex (текущий файл) Скачать версию ePub

Создано с помощью ExDoc (v0.40.3) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/security.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API