Caching Liveviews - Part 1: The road to HTTP-caching Liveviews
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:
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
[Update] The required changes have been merge into Phoenix and the implementation is a bit different that initially designed.
Until Phoenix is 1.7.15 is released, we need to add the Phoenix dependency from Github:
mix.exs
defp deps do
[
{:phoenix, github: "phoenixframework/phoenix", override: true},
# remaining deps
]
end
and use the new :check_csrf
option in your transports:
lib/cacheable_liveviews_web/endpoint.ex
:
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options], check_csrf: false],
longpoll: [connect_info: [session: @session_options], check_csrf: false]
Note that :check_origin
must be set to true
in your endpoint config, otherwise Phoenix
will not start (yes, even in dev). This is for your own safety.
[End of update]
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 insideconn.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.