Cross Site Scripting (XSS) Patterns in Phoenix

Michael Lubas, 2022-10-24

Cross Site Scripting (XSS) refers to a class of vulnerability in web applications, where an attacker is able to inject a script into the browsing context of a victim. The root cause of this vulnerability is untrusted user input being rendered in a web browser, where JavaScript written by an attacker is executed. If a website has user authentication, and an attacker is able to exploit XSS in the site, the end result is user accounts will be compromised.

Phoenix provides strong guard rails to prevent developers from introducing XSS into their application, however it is possible to bypass these protections and write insecure code. This article will use the Phoenix application cross to demonstrate what vulnerable code looks like. The source code for cross is posted on GitHub.

In a typical Phoenix project pages will be rendered with a standard controller/view/template pattern, shown below.

lib/cross_web/controllers/page_controller.ex

defmodule CrossWeb.PageController do
  use CrossWeb, :controller

  def render_a(conn, %{"i" => i}) do
    render(conn, "render_a.html", i: i)
  end
end

lib/cross_web/views/page_view.ex

defmodule CrossWeb.PageView do
  use CrossWeb, :view
end

lib/cross_web/templates/page/render_a.html.eex

<h2>User input (not vulnerable) </h2>
<%= @i %>

Phoenix.HTML details how attempting to inject HTML via user input will fail:

<%= "<hello>" %>

will be shown as:

&lt;hello&gt;

And we can confirm this is true.

The malicious input was rendered harmlessly. Most developers stay safely within the guards set by Phoenix, however it is still possible to bypass this protection with raw/1.

lib/cross_web/templates/page/render_b.html.eex

<h2>User input (vulnerable due to Phoenix.HTML.raw/1): </h2>
<%= raw @i %>

The script tags provided by the attacker are rendered in the victim’s browser session, causing the alert() box to trigger. Sobelow will detect this as “XSS.Raw”:

cross % mix sobelow
...
-----------------------------------------------

XSS.Raw: XSS - High Confidence
File: lib/cross_web/controllers/page_controller.ex
Line: 26
Function: render_b:25
Template: lib/cross_web/templates/page/render_b.html.eex - @i

-----------------------------------------------

What if a controller function is not using render? It is possible to send an HTML response directly, with Phoenix.Controller.html/2. If user input is used to build the response, it becomes a vector for XSS:

lib/cross_web/controllers/page_controller.ex

  def html_resp(conn, %{"i" => i}) do
    html(conn, "<html><head>#{i}</head></html>")
  end

The Sobelow finding for this pattern is “XSS.HTML”.

-----------------------------------------------

XSS.HTML: XSS in `html` - High Confidence
File: lib/cross_web/controllers/page_controller.ex
Line: 18
Function: html_resp:17
Variable: i

-----------------------------------------------

Similar to the above finding, using put_resp_content_type and send_resp in a pipeline to send an HTML response will also result in XSS:

lib/cross_web/controllers/page_controller.ex

  def send_resp_html(conn, %{"i" => i}) do
    conn
    |> put_resp_content_type("text/html")
    |> send_resp(200, "#{i}")
  end

This results in the Sobelow finding “XSS.SendResp”.

-----------------------------------------------

XSS.SendResp: XSS in `send_resp` - High Confidence
File: lib/cross_web/controllers/page_controller.ex
Line: 14
Function: send_resp_html:11
Variable: i

-----------------------------------------------

The examples given so far may seem unlikely to show up in a real codebase. The author of Sobelow, Griffin Byatt, gave an excellent ElixirConf talk that goes over XSS in Phoenix, beginning with the raw/1 example. He states, “I don’t think I’ve ever actually seen somebody do this”, referring to passing user input directly to Phoenix’s raw/1 function. He then goes into detail on how a common source of real-world XSS is file upload.

Let’s implement file upload in cross to show what the vulnerable code looks like. It will accept files of any type, and the upload will be accessible at /view_photo/:filename.

After upload:

Now let’s review the implementation:

lib/cross_web/router.ex

  scope "/", CrossWeb do
    pipe_through :browser
    ...
    get "/new_upload", PageController, :new_upload
    post "/upload", PageController, :upload
    get "/view_photo/:filename", PageController, :view_photo
  end

lib/cross_web/controllers/page_controller.ex

  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

lib/cross_web/templates/page/new_upload.html.eex

<h2>File Upload</h2>
<%= form_for @conn, Routes.page_path(@conn, :upload),
                    [multipart: true], fn f -> %>

    <%= file_input f, :upload, class: "form-control" %>
    <%= submit "Upload", class: "btn btn-primary" %>

<% end %>

Cross.ImgServer is where the file will be stored. Note that the backend storage (AWS S3, a filesystem, etc.) is not the source of the XSS vulnerability. A GenServer is used here for brevity.

lib/cross/img_server.ex

defmodule Cross.ImgServer do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def put(key, value) do
    GenServer.cast(__MODULE__, {:put, key, value})
  end

  def get(key) do
    GenServer.call(__MODULE__, {:get, key})
  end


  @impl true
  def init(m) do
    {:ok, m}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end
end

Take a closer look at upload/2 and view_photo/2:

  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

The user input given to upload determines the content-type of the file. There is no validation on the type of file being uploaded, meaning content-type can be set so an HTML page is rendered. This is the source of the vulnerability. Consider the file xss.html, with the contents:

<html><script>alert(1)</script></html>

When uploaded to cross, visiting https://localhost:4000/view_photo/xss.html will result in a call to view_photo/2 in the controller. The content-type of the response will be set to text/html, resulting in XSS.

Sobelow is capable of detecting this vulnerability, as XSS.ContentType:

-----------------------------------------------

XSS.ContentType: XSS in `put_resp_content_type` - Medium Confidence
File: lib/cross_web/controllers/page_controller.ex
Line: 43
Function: view_photo:39
Variable: content_type

-----------------------------------------------

When writing code for Phoenix, you are given strong XSS protections by default. Avoid using raw/1 on data controlled by an external user, be careful when rendering HTML outside of the standard controller/view/template pattern, and ensure your file uploads do not allow users to render HTML pages. Sobelow is an excellent tool to detect if your project is vulnerable to XSS.


Paraxial.io stops data breaches by helping developers ship secure applications. Get a demo or start for free.

Subscribe to stay up to date on new posts.