Caching Liveviews - Part 1: The road to HTTP-caching Liveviews

Posted on Aug 31, 2024

Since I release of HTTP caching libraries for Elixir and particularly plug_http_cache, the most frequent question has been if it’s possible to cache Liveviews.

Why caching Liveviews

The first reason is SEO, that is to speed up page loading to get a better page ranking. Search engines take into account many parameters, including the Time To First Byte which is why caching is important for some Phoenix apps. Although you can theoretically develop some pages with Liveview (not cacheable until today) and some others, with no interactive, in plain old Phoenix views (cacheable - but they’ll never have Liveview’s interactivity), developers tend to choose only one option. I’ve heard that the lack of cacheability has been a obstacle to adopting Liveview in some projects.

The second reason is performance and is due to how Liveview works. The page is rendered twice:

  • first mount, that we’ll call static mount, renders a static plain HTML page and sends it to the browser
  • the returned page includes Liveview’s javascript code, that initiates the second mount through a newly established websocket. Let’s call it the live mount

That is the second mount, the live mount, that enables interactivity by sending updates on the wire over time. However, the live mount also executes the same, initial rendering performed by the static mount.

For instance, if you’re showing a product page and are loading the first 20 products, then your Liveview will actually load these 20 products twice. Caching the first rendering, the static mount, allows saving ~50% of DB requests on these pages.

Enabling plug_http_cache on Liveviews

What if we try to enable plug_http_cache for Liveviews? Let’s try with the following configuration:

project_web/router.ex

  pipeline :caching do
    plug PlugCacheControl, directives: [:public, s_maxage: {1, :hour}]
    plug PlugHTTPCache, @caching_opts
  end

  # ...
  scope "/", ProjectWeb do
    pipe_through :browser

    live_session :default do
      pipe_through :caching

      live "/", MainLive.Index
    end
  end

Then let’s refresh the page to check if the page is cached:

First attempt at caching Liveviews

The age header shows our Phoenix app that we’ve crafted for the sake of this experiment has returned the cached page.

Hurray! Too good to be true, right? Yes.

When trying in another browser, simulating another user, the page keeps reloading with the following exception:

[debug] LiveView session was misconfigured or the user token is outdated.

1) Ensure your session configuration in your endpoint is in a module attribute:

    @session_options [
      ...
    ]

2) Change the `plug Plug.Session` to use said attribute:

    plug Plug.Session, @session_options

3) Also pass the `@session_options` to your LiveView socket:

    socket "/live", Phoenix.LiveView.Socket,
      websocket: [connect_info: [session: @session_options]]

4) Ensure the `protect_from_forgery` plug is in your router pipeline:

    plug :protect_from_forgery

5) Define the CSRF meta tag inside the `<head>` tag in your layout:

    <meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />

6) Pass it forward in your app.js:

    let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
    let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}});

despite the fact that all of this is correctly configured.

Digging further, it turns out that the CSRF check fails. When setting up the websocket connection, Phoenix checks that the CSRF token embedded in the page rendered during the static mount (5th item in the error message above) matches with the CSRF value stored in the user’s cookie.

(Note that when Liveview is configured without a session, plug_http_cache works out of the box as the session is not retrieved and the CSRF check not performed.)

However, we want to serve a page generated for user A, with a CSRF token generated exclusively for user A, to user B. It cannot work as the CSRF verification value stored in the cookie will is different for user B.

Can we get rid of the CSRF token check? That’s a security mechanism after all, usually we avoid removing them whatever the reason.

CSRF & CSWSH

Phoenix makes use of CSRF tokens to protect against CSRF attacks. I’ll assume the reader knows what a CSRF attack is (there’s plenty of resources if not).

Phoenix’s forms automatically protects forms with a CSRF token. The plug :protect_from_forgery is installed by default in the browser pipeline in Phoenix’s router.

Last years, CSRF attacks have largely been mitigated by the new SameSite cookie attribute. When a cookie is set to Lax or Strict, it prevents most CSRF attacks from happening because cookies (and thus authentication cookies) are not longer sent from domains other than yours.

For instance, evil.com cannot use XHR to fetch authenticated content from your site - authentication cookies are discarded.

All major browsers now implement it and default to sane values (Lax).

Except it doesn’t apply to WebSockets, as explained in this article. When initiating a ws or wss request, the browsers send all the cookies, ignoring the SameSite cookie attribute. The article gives the following example:

GET /trading/ws/stockPortfolio HTTP/1.1
Host: www.some-trading-application.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:23.0) Firefox/23.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: de-de,de;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
Sec-WebSocket-Version: 13
Origin: https://www.some-evil-attacker-application.com
Sec-WebSocket-Key: hP+ghc+KuZT2wQgRRikjBw==
Cookie: JSESSIONID=1A9431CF043F851E0356F5837845B2EC
Connection: keep-alive, Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket

Notice that:

  • the cookie is sent
  • the origin is https://www.some-evil-attacker-application.com

Unless the Websocket doesn’t allow any user interaction and doesn’t expose private information, any website can send messages and retrieve user private data exposed but the Websocket or worse (performing destructive operations…). This is what is called a Cross-Site WebSocket Hijacking attack, or CSWSH.

In the context of Phoenix’s Liveview, we need to make sure that the Websocket originates from us. There’s 2 ways to deal with it:

  • use A CSRF token
  • check the origin header

Phoenix does both, and allows disabling origin check at your own perils. CRSF token check is, however, hard-coded in Phoenix’ socket session setup code.

I believe that checking the origin header is sufficient from a security point of view, but I’d be glad to have confirmation from the community. Until that, don’t deploy this into production unless you trust my security analysis skills!

The WebSocket RFC doesn’t say otherwise:

Servers that are not intended to process input from any web page but only for certain sites SHOULD verify the |Origin| field is an origin they expect. If the origin indicated is unacceptable to the server, then it SHOULD respond to the WebSocket handshake with a reply containing HTTP 403 Forbidden status code.

So let’s try to disable CSRF check.

Disabling CSRF check

The change consist in adding an option to disable CSRF check in Phoenix. The change are hosted on the feat-socket-optionnal-csrf-check branch of my Phoenix fork. The core of the changes take a few lines:

diff --git a/lib/phoenix/socket/transport.ex b/lib/phoenix/socket/transport.ex
index a1c5fd54..2c924cdc 100644
--- a/lib/phoenix/socket/transport.ex
+++ b/lib/phoenix/socket/transport.ex
@@ -489,13 +489,10 @@ defmodule Phoenix.Socket.Transport do
   defp connect_session(conn, endpoint, {key, store, {csrf_token_key, init}}) do
     conn = Plug.Conn.fetch_cookies(conn)

-    with csrf_token when is_binary(csrf_token) <- conn.params["_csrf_token"],
-         cookie when is_binary(cookie) <- conn.cookies[key],
+    with cookie when is_binary(cookie) <- conn.cookies[key],
          conn = put_in(conn.secret_key_base, endpoint.config(:secret_key_base)),
          {_, session} <- store.get(conn, cookie, init),
-         csrf_state when is_binary(csrf_state) <-
-           Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]),
-         true <- Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token) do
+         true <- maybe_check_csrf(conn, endpoint, session, csrf_token_key) do
       session
     else
       _ -> nil
@@ -542,6 +539,18 @@ defmodule Phoenix.Socket.Transport do
     end
   end

+  defp maybe_check_csrf(conn, endpoint, session, csrf_token_key) do
+    if endpoint.config(:socket_check_csrf, true) do
+      with csrf_token when is_binary(csrf_token) <- conn.params["_csrf_token"],
+           csrf_state when is_binary(csrf_state) <-
+             Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]) do
+         Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
+      end
+    else
+      true
+    end
+  end
+
   defp check_origin_config(handler, endpoint, opts) do
     Phoenix.Config.cache(endpoint, {:check_origin, handler}, fn _ ->
       check_origin =

and adding the socket_check_csrf endpoint option.

Once you disable CSRF check, origin verification is the only security measure that protects you from epic failure in the form of leaking your client’s data, so be extra careful!

This change is not integrated in Phoenix, work on it will follow this post. Until then, I’d recommend you maintain your own fork of phoenix after cherry-picking this commit, if you want to enable caching Liveviews.

Configuring plug_http_cache for Liveview

Back to our sample project, that we’ll call CacheableLiveviews from now on, we’ll setup HTTP caching for Liveviews

First we need to setup the forked Phoenix:

mix.exs

  defp deps do
    [
      {:phoenix,
       github: "tanguilp/phoenix", branch: "feat-socket-optionnal-csrf-check", override: true},
      # remaining deps
    ]
  end

and use the new socket_check_csrf option to false:

config.exs

config :project, Project.Endpoint,
  url: [host: "localhost"],
  adapter: Phoenix.Endpoint.Cowboy2Adapter,
  # ...
  socket_check_csrf: false

Then we delete the logic of getting the CSRF token in the Phoenix’s javascript - we no longer use it for this purpose:

assets/js/app.js

-let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
-let liveSocket = new LiveSocket("/live", Socket, {
-  longPollFallbackMs: 2500,
-  params: {_csrf_token: csrfToken}
-})
+let liveSocket = new LiveSocket("/live", Socket, {longPollFallbackMs: 2500})

We can also remove the CSRF token from the rendered HTML in the root layout:

lib/cacheable_liveviews_web/components/layouts/root.html.heex

--- a/lib/cacheable_liveviews_web/components/layouts/root.html.heex
+++ b/lib/cacheable_liveviews_web/components/layouts/root.html.heex
@@ -3,7 +3,6 @@
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
     <title>Cached private Liveview</title>
     <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
     <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>

You can also remove the :protect_from_forgery plug, if you don’t use it otherwise:

--- a/lib/cacheable_liveviews_web/router.ex
+++ b/lib/cacheable_liveviews_web/router.ex
@@ -7,7 +7,6 @@ defmodule CacheableLiveviewsWeb.Router do
     plug :accepts, ["html"]
     plug :fetch_session
     plug :fetch_live_flash
-    plug :protect_from_forgery
     plug :put_root_layout, html: {CacheableLiveviewsWeb.Layouts, :root}
     plug :put_secure_browser_headers
     plug CacheableLiveviewsWeb.Plug.SetASessionNoMatterWhat

It remains to configure caching. We’ll do it a bit differently that the way proposed at the top of this article. Although there are some Liveviews that we definitely want to cache, we would like to have finer control on those we cache and those we don’t cache.

Indeed some Liveviews are not really intended to be cached. In the following example:

live "/resource/:resource_id", SomeProject.Index, :index
live "/resource/:resource_id/:edit", SomeProject.Index, :edit

we would like to cache the first, but not the second. Let’s add a parameter to these routes we want to cache:

live_session :default do
  live "/", MainLive.Index, private: %{cache: true}
end

According to the documentation, :private is:

an optional map of private data to put in the plug connection, for example: %{route_name: :foo, access: :user}. The data will be available inside conn.private in plug functions.

Another issue is that inside a Liveview module, you have no access to the %Plug.Conn{} connection and therefore we can’t set the cache-control header. To bypass this issue, we’ll use a callback to set the cache-control header before caching the response.

Let’s define a new caching pipeline:

lib/cacheable_liveviews_web/router.ex

pipeline :caching do
  plug PlugHTTPCache, @caching_opts
  plug CacheableLiveviewsWeb.Plug.CacheLiveviewResponse, 60 * 60 * 6
end

Both PlugHTTPCache and CacheableLiveviewsWeb.Plug.CacheLiveviewResponse install a callback. Keep in mind callbacks are executed in the reverse order in which they’re installed. This means the callback installed by CacheableLiveviewsWeb.Plug.CacheLiveviewResponse will be executed first. Let’s define it:

lib/cacheable_liveviews_web/plug/cache_liveview_response.ex

defmodule CacheableLiveviewsWeb.Plug.CacheLiveviewResponse do
  @behaviour Plug

  @impl true
  def init(duration_second) do
    duration_second
  end

  @impl true
  def call(conn, duration_second) do
    Plug.Conn.register_before_send(conn, set_cache_control_header(duration_second))
  end

  defp set_cache_control_header(duration_second) do
    fn
      %Plug.Conn{private: %{cache: true}} = conn ->
        PlugCacheControl.Helpers.put_cache_control(conn, [:public, s_maxage: duration_second])

      conn ->
        conn
    end
  end
end

Notice that if the cache: true private option is not set, we return the conn without setting cache-control headers.

Using such a callback can be used to set other headers, such as the vary header if you have A/B tested or internationalized Liveviews for instance. In this case, you can use the process dictionary from inside the Liveview’s callbacks and read it in such a callback to set the value before caching.

Now caching should work 🥳

Limitations

When using live sessions, the static mount is executed only when the websocket is initiated, that is for the first visited page only. Be aware of that if you want to improve your SEO: you might need an extra script to run after deployment, which visits all the pages you want to be cached before search robots crawl it, otherwise they might be the first to visit them and take the performance hit of the first rendering.

Phoenix Liveview requires a session to be set, which might not be the case if you get rid of the now unnecessary :protect_from_forgery plug. In this case, just set a random cookie value and you’re done.

Summary

In this post we’ve seen:

  • why Liveview is not cacheable by default: the CSRF check is embedded in the rendered HTML and tied to the user that visited the page first
  • what is a CSWSH attack and how we can defend ourselves against it
  • how it’s relatively simple to disable CSRF check for Liveview, at the expense of disabling one of the 2 security mecanisms protecting against CSWSH
  • how to configure PlugHTTPCache for Liveviews and selectively set cache control headers

Caching Liveviews would also work with any CDN, in addition or without PlugHTTPCache.

So far we’ve talked about the user session without considering that the pages we cache could contain private data from this session. Is that possible that we could publicly cache private user data by mistake and leak personal data?

Absolutely, but there is a way to cache such Liveviews anyway, without leaking private user data: Part 2: Publicly caching private Liveviews.