Running a Phoenix App in an iframe

For a side project, I am working on building a Jira Connect application using Elixir and Phoenix. With a Connect application, your UI is rendered within Jira as though it is a part of Jira itself. This in-Jira UI rendering is supported by the use of iframes.

To get this to work with my Phoenix application, I needed to:

  1. Update the Content-Security-Policy response headers to enable some pages to be embeddable in an iframe
  2. Update the SameSite settings for the session cookie to allow the cookies to be used by a third-party

In this post, I will cover how I accomplished both of these tasks.

Content-Security-Policy

The Content-Security-Policy headers provide a mechanism for the server to tell the browser what content is safe to load. A common use case for this is to help prevent XSS attacks by blocking all JavaScript not explicitly listed in the Content-Security-Policy.

In this case, we want our server to tell the browser on which domain we approve rendering our iframe. For this, we use the frame-ancestors directive.

The fame-ancestors directive expects a list of sources (e.g., URLs) that should be allowed to render the content in an iframe. For the Connect application, I wanted to allow the iframe to be renderable in any cloud instance of Jira. To do this, I was able to leverage the ability to use wildcard matchers to match any subdomain of atlassian.net - https://*.atlassian.net.

I also decided to include the 'self' source. This source enables iframes to be renderable when the iframe's src matches the origin of the page that is rendering it. I am using this to enable manual testing. Depending on your needs and testing strategy, you may be able to remove it.

With these two pieces in mind, my goal was to end up with a Content-Security-Policy header that matched:

Content-Security-Policy: frame-ancestors 'self' https://*.atlassian.net

Allow iframe Plug

To add this response header, we will take advantage of Phoenix's use of Plug. Plug is a library that allows you to interact with HTTP requests and responses and makes up the core of Phoenix's HTTP lifecycle.

Testing the Plug

We can use the Plug.Test module to test-drive our new plug.

# test/my_app_web/plugs/allow_iframe_test.exs
defmodule MyAppWeb.Plugs.AllowIframeTest do
  use ExUnit.Case, async: true
  use Plug.Test

  alias MyAppWeb.Plugs.AllowIframe

  test "updates Content-Security-Policy headers" do
    # Create a test connection
    conn =
      conn(:get, "/hello")
      # Invoke the plug
      |> AllowIframe.call(%{})

    # Get the `Content-Security-Policy`
    # from the response headers
    csp_headers =
      Enum.find(
        conn.resp_headers,
        fn {header, _value} ->
          header == "content-security-policy"
        end
      )

    # Confirm it includes the `frame-ancestors` directive
    # with 'self' and our Atlassian wildcard matcher
    assert(
      csp_headers == {
        "content-security-policy",
        "frame-ancestors 'self' https://*.atlassian.net;"
      }
    )
  end
end

This test will create a conn and pass that into our soon-to-be plug. It then asserts that our conn will have response headers that include our expected Content-Security-Policy and frame-ancestors pairing.

With a failing test in place, we can now create a plug that will make it pass.

Creating the Plug

To implement our AllowIframe plug, we will create a module plug.

In our call function we will use put_resp_header/3 to update the response headers to include the Content-Security-Policy header with the frame-ancestors directive. The end result should look something like:

# lib/my_app_web/plugs/allow_iframe.ex
defmodule MyAppWebb.Plugs.AllowIframe do
  @moduledoc """
  Allow rendering in iframes for Jira.
  Uses `Content-Security-Policy: fame-ancestors`
  which limits where the app can be loaded as
  an `iframe` to only specified hosts.
  """

  import Plug.Conn

  def init(_), do: %{}

  def call(conn, _opts) do
    put_resp_header(
      conn,
      "content-security-policy",
      "frame-ancestors 'self' https://*.atlassian.net;"
    )
  end
end

Plugging it In

For our use case, we are going to add our plug to the router. Router plugs allow us to manage their usage on a per-route level instead of a per-controller or more global (endpoint) basis.

To use a plug in the router, it must be included in a pipeline. Since we are building a Connect application, I decided to make a pipeline to group routes that will be renderable within the Jira UI.

# a part of the Jira Connect application
pipeline :jira do
  plug MyAppWeb.Plugs.AllowIframe
end

I did not add this directly to the existing browser pipeline because I anticipated the need to have routes that would not be rendered within the Jira UI (and therefore not in an iframe). While your needs will be different, please remember the ability to render iframes is limited for security purposes. I would suggest trying to limit the ability to display your application in an iframe as much as possible and allow access on an as-needed basis instead of defaulting to making it available.

With the pipeline in place, I can group routes that will be rendered in the Jira Connect application together. To do this, I leverage Phoenix's scope block. This groups all routes to be nested under /jira. This grouping allows us to enable our AllowfIframe plug for all of these routes at the same time.

scope "/jira", MyAppWeb.Jira, as: :jira do
  pipe_through [:browser, :jira]

  live "/", PageLive, :index
  get "/issue/:id", IssueController, :show
end

Cookies

At this point, we can render our application within an iframe in Jira (or wherever you set your frame-ancestors). However, even though your application is renderable, you may find your application does not work quite right. For me, I found the application would repeatedly reload when trying to view a LiveView page. You may also see problems when interacting with forms or managing user-session information.

The Phoenix server tried to log a message hinting at the problem:

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

This log message also included suggestions for resolving the issue. One of the recommendations was to make sure to include the CSRF token in the HTML and the JavaScript used to connect the LiveView socket. When looking at the server logs, I notice the _csrf_token was included in the parameters when attempting to connect to the socket, but that it was changing with every page reload and socket reconnect attempt.

After some digging, I eventually discovered that the cookie, which stores information like the CSRF token, was not getting set when loading the iframe from within Jira.

Our cookie was not getting set because the default behavior for cookies is to be first-party, meaning they are only accessible on the same domain as the server. Since we are rendering our site in an iframe we are attempting to access the cookies for our application from an Atlassian/Jira domain. Working with cookies from a different domain is known as third-party cookies. For security reasons, browsers block this type of cookie by default.

For your application to create cookies that can be accessible by a third party, the cookie must have the SameSite property set to 'None'. When sending cookies to third parties, you must also set the Secure property on the cookie; this indicates that the cookie should only be accessible when the requesting site is using https. For more information on the SameSite options when working with cookies check out this article.

Third Party Cookies with Phoenix

Phoenix has a Plug.Session that is responsible for handling session cookies and stores. While we added the plug that we created above into the router, this plug is an Endpoint plug that Phoenix created at project generation time. In your project's endpoint module (lib/my_app_web/endpoint.ex), you should have a line like:

plug Plug.Session, @session_options

This file should also include a module attribute named @session_options. On a recently generated Phoenix application, it should look like:

# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
  store: :cookie,
  key: "_my_app_key",
  signing_salt: "Salt"
]

This is where we can set the additional Plug.Session options, including :same_site and :secure. Our @session_options should now look something lke:

@session_options [
  store: :cookie,
  key: "_my_app_key",
  signing_salt: "Salt"
  same_site: "None",
  secure: true
]

Same Problem, Different Environment

With the cookie set up to be available as a third-party cookie, we should no longer see constant reloading when rendering our iframe on a third-party site. However, setting the secure option on our session cookie may cause us some problems in local development. The secure flag requires communication over HTTPS. For some browsers, this extends to localhost as well. The need for HTTPS means that you will likely now face the same issues you did when interacting with your app in an iframe in your local development environment.

To allow my application's cookies to get set during local development, I decided to set up SSL in development. While some browsers will warn about using self-signed certificates (even on localhost), I prefer this method over conditionally setting the secure attribute on the cookies. I worry that the divergence in options between development and production could lead to hard to reproduce bugs or accidentally setting less secure options in production.

For local HTTPS development, Phoenix provides a mix task to generate self-signed certificates (mix phx.gen.cert). You can then update the application's Endpoint to serve https and use the self-signed certificates by updating config/dev.exs.

# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
  https: [
    port: 4000,
    cipher_suite: :strong,
    keyfile: "priv/cert/selfsigned_key.pem",
    certfile: "priv/cert/selfsigned.pem"
  ],

Please reference the Phoenix documentation for the most up-to-date way to do this.

If you write feature tests with a tool that relies on a headless browser, you will also need to update your Endpoint settings in config/test.exs to serve over https. These changes should be similar to the changes we made above for the dev environment.

One additional change I need to make for testing was to tell ChromeDriver that it was okay to interact with our self-signed, less secure certificates when interacting with localhost. ChromeDriver provides the --allow-insecure-localhost flag to do just that.

I am using Wallaby for testing and set this flag with the following config:

# config/test.exs`
config :wallaby,
       :chromedriver,
       capabilities: %{
         chromeOptions: %{
           args: ["--allow-insecure-localhost"]
         }
       }

Despite not being the norm, this setup has not had any problems (yet) and has had the advantage of working with the secure cookie settings.

Conclusion

At this point, you should now be able to render your application within an iframe. If you copied the Content-Security-Policy exactly, you would be limited to self-served and Atlassian iframes, but, hopefully, you will be able to tweak our AllowIframe plug to fit your application's needs.


Notice something wrong? Please consider proposing an edit or opening an issue.