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:
<hello>
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.