Client-Side Enforcement of LiveView Security
Posted 2023-02-24 17:37:28.138248
One of the classical security vulnerabilities in web applications is “Client-Side Enforcement of Server-Side Security” (CWE-602). In the OWASP Top 10 of 2021 it appeared in 1st place as part of “A01:2021 – Broken Access Control”.
This issue is pretty well understood by most web developers, but I have seen it resurface recently in Phoenix LiveView applications. In this post we will look at what it takes to prevent such issues in a LiveView application. But we will start by reviewing server-side access controls in non-LiveView apps.
Traditional Phoenix web app
In a traditional Phoenix web application, where the server renders HTML and each link or button click triggers a new HTTP request, access controls typically need to be considered in two places: in page rendering, and in request dispatching.
During rendering the application needs to decide what parts of the page to make available to the current user depending on their permissions. This may mean omitting certain information, such as restricted parts of the application’s data model, or hiding certain menu items, links or buttons that represent actions not available to the current user.
When dispatching an incoming request, in the application’s router, within a controller or in a context module, the application should verify the current user’s permissions, and deny unauthorized requests with an HTTP 401/403 error response. Hiding the menu items, links or buttons pointing to these routes during rendering is done for the user’s benefit, but the actual enforcement must be done on the server when requests are handled.
Single Page Application (SPA)
In a Single Page Application (SPA), much of the rendering is handled by client-side code, with the server providing data through JSON APIs. The server still needs to ensure information disclosure reflects the user’s permissions, as users can inspect the JSON API responses in the browser. And the server must implement access controls for API operations, again in the router, controller actions or context module.
Depending on the application architecture the server may or may not play a role in enabling/disabling menu items and showing/hiding links or buttons. It is possible the server indicates to the client-side application which actions are permitted, e.g. through a "_links"
field in the JSON object. Or the client-side code may be responsible alone for reflecting the user’s permissions in the UI, which may be a bit harder to keep in-sync with the server-side enforcement.
Phoenix LiveView
The Phoenix LiveView life-cycle model is much more stateful than that of traditional web applications. As a result it is a bit harder to recognize the possible entry points for unauthorized requests. If the router does not show a separate “delete” action, is there even a way to trigger that action outside of an authorized session? Is it not enough to just not wire up the “delete” event when the user does not have the necessary permissions?
No, it is not. And the Phoenix LiveView documentation is pretty clear about that. Let’s have a closer look to reinforce our understanding of the risk.
For our very simple example, let’s say we want to allow only certain users to delete blog posts. We can hide the “Delete” button in the Heex template:
<:action :let={{id, post}}>
<%= if allow_delete?(post, @current_user) do %>
<.link
phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
<% end %>
</:action>
Unfortunately a user can just add back the HTML for that button by using the browser’s dev tools panel. For instance, the following HTML would do the trick:
<a href="#" phx-click='[["push",{"event":"delete","value":{"id":123}}]]'>
Delete
</a>
Or, alternatively, the following statement executed in the browser’s console would trigger the same event:
window.liveSocket.main.channel.push("event", {"type":"click","event":"delete","value":{"id":123}})
To prevent such unauthorized requests, the server needs to check the user’s permissions in the event handler, e.g.:
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(id)
if allow_delete?(post, socket.assigns[:current_user]) do
{:ok, _} = Blog.delete_post(post)
{:noreply, stream_delete(socket, :posts, post)}
else
Logger.warn("Unauthorized blog post delete request!")
{:noreply, socket}
end
end
In the end this is very similar to the way server-side access controls are implemented in non-LiveView applications. The key thing to remember is that the LiveView socket is entirely under the user’s control, just as individual HTTP requests are: the server should not make any assumptions about the source of those events, and thus treat them as untrusted input.
This extends to other aspects of input validation too: the user may try to change the post ID, potentially leading to Insecure Direct Object Reference (IDOR) vulnerabilities, or they might use the event parameters to attempt injection attacks, such as XSS, SQL injection or command injection.
Back