Setting session option at runtime for endpoint

I am trying to set my Endpoint plug(Plug.Session, session_options) with runtime values.

Specifically, I’d like session_options to have key being defined by env var read inside runtime.exs.

But plug is called directly at the module level and I cannot find a way to dynamically set the session key.

The Plug.Session source code is straightforward enough: plug/session.ex at v1.12.1 · elixir-plug/plug · GitHub

So you could make your own wrapper plug that obtains the config at runtime (i.e. inside call instead of init) and then calls Plug.Session.call(conn, config).

I did not think of just wrapping the session plug.

It is also possible to make this dynamic?

  socket("/live", Phoenix.LiveView.Socket,
    websocket: [connect_info: [session: @session_options]] # replacing @session_options with runtime values
  )

The thing is, wrapping the session plug would then read the configuration on every call and I’d like to avoid that. I’d rather have all the runtime configuration read once when the app boot.

I’m not sure, check the docs for it and possibly the source to see.

As for “reading the configuration”, you can do that once in runtime.exs and then set the configs there. At runtime (in call), you would just use Application.get_env which is a cheap operation and not something to worry about unless you have actually benchmarked it to be an issue.

Yeah, actually is it baked in a compile time.

But I think I’m not going to hack it through, and I’ll just keep it compile time and document it accordingly.

Two years ago I worked in a proof of concept (PoC) where I used this type of approach. See the PoC here:

@session_options {NotesWeb.Endpoint.RuntimeSession, :options, []}

defmodule RuntimeSession do
  def init(_opts) do
    options()
    |> Plug.Session.init()
  end

  def call(conn, opts) do
    Plug.Session.call(conn, opts)
  end

  def options() do 
    Application.fetch_env!(:notes, NotesWeb.Endpoint)[:session_options]
  end
end

On the endpoint.ex:

# plug Plug.Session 
plug NotesWeb.Endpoint.RuntimeSession

And then in runtime.exs:

config :phoenix360, Phoenix360Web.Endpoint,
  # To be retrieved at runtime by `Phoenix360Web.PlugSession.options/0`.
  session_options:
    [
      store: :cookie,
      key: "_" <> System.fetch_env!("PHOENIX360_SESSION_COOKIE_KEY_NAME"),
      signing_salt: System.fetch_env!("PHOENIX360_SESSION_SIGNING_SALT"),
      encryption_salt: System.fetch_env!("PHOENIX360_SESSION_ENCRYPTION_SALT"),
    ],

A concrete example for the endpoint.ex on the app I am developing:

defmodule ApiBaasWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :api_baas

  @session_options {ApiBaasWeb.Endpoint.PlugSessionRuntimeConfig, :options, []}

  defmodule PlugSessionRuntimeConfig do
    def init(_opts) do
      IO.puts("PLUG SESSION INIT")

      options()
      |> Plug.Session.init()
    end

    def call(conn, opts) do
      IO.puts("PLUG SESSION CALL")

      Plug.Session.call(conn, opts)
    end

    def options() do
      IO.puts("PLUG SESSION OPTIONS")

      Application.fetch_env!(:api_baas, ApiBaasWeb.Endpoint)[:session_options]
    end
  end

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

  # Serve at "/" the static files from "priv/static" directory.
  #
  # You should set gzip to true if you are running phx.digest
  # when deploying your static files in production.
  plug Plug.Static,
    at: "/",
    from: :api_baas,
    gzip: false,
    only: ~w(assets fonts images favicon favicon.ico site.webmanifest browserconfig.xml robots.txt)

  # Code reloading can be explicitly enabled under the
  # :code_reloader configuration of your endpoint.
  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :api_baas
  end

  plug Phoenix.LiveDashboard.RequestLogger,
    param_key: "request_logger",
    cookie_key: "request_logger"

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug ApiBaasWeb.Endpoint.PlugSessionRuntimeConfig
  plug ApiBaasWeb.Router
end

This would set all the options at compile time since plug’s init callback is called at compile time (and the plug macro is also handled at compile time).

It’s being used during runtime as per what I see from IO.puts:

So, it’s being used on each request, and I battled a lot around this to find a way of being only called during boot, but never found a solution.

Or, am I misunderstanding something?

This is a red hering, as in development init is indeed called at runtime due to a config in dev.exs:

# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

In production builds init/1 is called at compile time and therefore you cannot change those options at runtime anymore.

2 Likes

Here it goes development parity with production to the trash…

Thanks for the head-ups.

I keep being bitten and trapped by this unfortunate decisions, that privilege developer convenience over development environment consistence. Don’t get me wrong, because this is not a problem exclusive to Elixir.

What are the trade-offs of setting config :phoenix, :plug_init_mode, :runtime for production?

I really don’t want to have signing_salt and encryption_salt being set at compile time, because this is a serious security risk that cannot be overlooked from someone that works in Mobile and API security, like me.

Those values are used together with secret_key_base, which is normally pulled from the environment in config/runtime.exs:

Under the hood, those salt values are used with Plug.Crypto.KeyGenerator’s PBKDF2 implementation to derive the actual secrets.

If you’re not satisfied that PBKDF2 actually reduces the need for additional secret material, you can also pass a module-function-args tuple to encryption_salt and signing_salt. For instance:

# in the endpoint or session_options
plug Plug.Session, [
  store: :cookie,
  key: "blargh_session",
  signing_salt: {SecretStuff, :signing_salt, []},
  encryption_salt: {SecretStuff, :encryption_salt, []}
]

# elsewhere
defmodule SecretStuff do
  def signing_salt, do: System.fetch_env!("PHOENIX360_SESSION_SIGNING_SALT")
  def encryption_salt, do: System.fetch_env!("PHOENIX360_SESSION_ENCRYPTION_SALT")
end

EDIT: on further reflection, this may even be too much runtime behavior. Plug.Session.COOKIE will call the MFA every time, even though key generation is subsequently cached in Plug.Key. :thinking:

2 Likes

Thanks for your very detailed answer, and to take the time for writing it :slight_smile:

My issue with @session_options is that it’s populated at compile time.

I can change the endpoint.ex to do it like this:

 @session_options [
    store: :cookie,
    key: "_api_baas_key",
    signing_salt: System.fetch_env!("SESSION_SIGNING_SALT"),
    encryption_salt: System.fetch_env!("SESSION_ENCRYPTION_SALT"),
  ]

But then I get:

$  mix compile
Compiling 1 file (.ex)

== Compilation error in file lib/api_baas_web/endpoint.ex ==
** (ArgumentError) could not fetch environment variable "SESSION_SIGNING_SALT" because it is not set
    (elixir 1.13.4) lib/system.ex:698: System.fetch_env!/1
    lib/api_baas_web/endpoint.ex:9: (module)

I can solve this by falling back to some dummy default values, but then I risk that a release may be run with them.

Another approach is to set the compile environment with some default dummy values for this env vars, but that is an hack that I was trying to avoid.

I am already using MFA, just in a slight different way:

In my understanding, despite my approach being a little different achieves the same result with the same drawbacks you mentioned:

Or am I not really understanding it?

Yes, I also don’t like the fact that the MFA is called in every request, but I haven’t found other way of achieving what I want.

Overriding the endpoint init/2 callback doesn’t allow me configure the session and the live socket session.

For now I will adopt your suggested approach:

defmodule ApiBaasWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :api_baas

  defmodule RuntimeEnv do
    def signing_salt(), do: System.fetch_env!("SESSION_SIGNING_SALT")
    def encryption_salt(), do: System.fetch_env!("SESSION_ENCRYPTION_SALT")
  end

  @session_options [
    store: :cookie,
    key: "_api_baas_key",
    signing_salt: {RuntimeEnv, :signing_salt, []},
    encryption_salt: {RuntimeEnv, :encryption_salt, []}
  ]

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

  # Serve at "/" the static files from "priv/static" directory.
  #
  # You should set gzip to true if you are running phx.digest
  # when deploying your static files in production.
  plug Plug.Static,
    at: "/",
    from: :api_baas,
    gzip: false,
    only: ~w(assets fonts images favicon favicon.ico site.webmanifest browserconfig.xml robots.txt)

  # Code reloading can be explicitly enabled under the
  # :code_reloader configuration of your endpoint.
  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :api_baas
  end

  plug Phoenix.LiveDashboard.RequestLogger,
    param_key: "request_logger",
    cookie_key: "request_logger"

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug ApiBaasWeb.Router

end

It would be awesome if Phoenix allowed to configure this from the endpoint init/2 callback :slight_smile:

Calling a function is not necessarily bad. If things get slowed down or not depends where you fetch the configuration from.

1 Like

In this case the MFA to retrieve the two env vars will be invoked literally for every request with a session, thus I wonder how much impact this will have at scale.

If my project succeeds this may become a bottleneck, but shipping a relase to each customer with the same signing and encryption salt is out of question, and building a release for each of them with different ones and be the one in charge of keeping them securely stored is also out of equation.

Also, building binaries with any kind of sensitive info on them is not a wise decision in terms of security. The CI systems are not trustworthy to handle sensitive info, because they may leak them into the logs, like Travis does, and they say its by design :astonished:. Plus your release binary will now have sensitive info to be easily reverse engineered, and how do you secure this release binary against extraction of such sensitive info when it falls in the wrong hands. For example, as part of a data-breach/leak on whatever system used as part of the developer and devops workflow, like CI/CD pipelines (Travis, etc.), storage providers(S3 buckets, etc.), communication tools (Slack, Jira, email, etc.).

Now that I mention that I may need to revisit the architecture of my project and see if ditching Phoenix as much as possible and just use Elixir will be a wiser choice :thinking: